+ {{ previewText }} +
+ +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 @@
+
+
+
+ {{
+ t(
+ 'HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DESCRIPTION'
+ )
+ }}
+
+ {{ $t('BILLING_SETTINGS.TOPUP.FETCH_ERROR') }}
+
- {{
- $t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
- }}
- {{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
-
+ {{
+ $t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
+ }}
+ {{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
+
+ {{ $t('DATA_IMPORTS.EXPORT.DESCRIPTION') }}
+
+ {{ $t('DATA_IMPORTS.TABLE.EMPTY_DESCRIPTION') }}
+
+ {{ emptyMessage }}
+
+ {{ previewText }}
+
+
+
+
+ {{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.TITLE') }}
+
+
- {{ title }}
-
+
+ {{ title }}
+
+
+ {{ $t('DATA_IMPORTS.EXPORT.TITLE') }}
+
+
+ {{ $t('DATA_IMPORTS.TABLE.EMPTY') }}
+
+
+
+
+ {{ title }}
+
+ {{ title }}
+
+ {{ count }}
+
+
+ {{ title }}
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js b/app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js
new file mode 100644
index 000000000..1e81d3e9a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js
@@ -0,0 +1,34 @@
+import { FEATURE_FLAGS } from '../../../../featureFlags';
+import { frontendURL } from '../../../../helper/URLHelper';
+import SettingsWrapper from '../SettingsWrapper.vue';
+import Index from './Index.vue';
+import Show from './Show.vue';
+
+export default {
+ routes: [
+ {
+ path: frontendURL('accounts/:accountId/settings/data'),
+ component: SettingsWrapper,
+ children: [
+ {
+ path: '',
+ name: 'settings_data_imports',
+ component: Index,
+ meta: {
+ featureFlag: FEATURE_FLAGS.DATA_IMPORT,
+ permissions: ['administrator'],
+ },
+ },
+ {
+ path: ':dataImportId',
+ name: 'settings_data_import_show',
+ component: Show,
+ meta: {
+ featureFlag: FEATURE_FLAGS.DATA_IMPORT,
+ permissions: ['administrator'],
+ },
+ },
+ ],
+ },
+ ],
+};
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/importSources.js b/app/javascript/dashboard/routes/dashboard/settings/data/importSources.js
new file mode 100644
index 000000000..85e832a6c
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/importSources.js
@@ -0,0 +1,17 @@
+export const IMPORT_SOURCES = [
+ {
+ value: 'intercom',
+ label: 'Intercom',
+ icon: '/dashboard/images/integrations/intercom.png',
+ },
+];
+
+const DEFAULT_IMPORT_SOURCE = {
+ value: 'file',
+ label: 'File import',
+ iconClass: 'i-lucide-file-text',
+};
+
+export const importSourceFor = dataImport =>
+ IMPORT_SOURCES.find(source => source.value === dataImport?.source_provider) ||
+ DEFAULT_IMPORT_SOURCE;
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js b/app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js
new file mode 100644
index 000000000..f658ee04c
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js
@@ -0,0 +1,84 @@
+export const POLL_INTERVAL_MS = 5000;
+
+export const ACTIVE_IMPORT_STATUSES = ['pending', 'processing'];
+
+export const isActiveImport = dataImport =>
+ ACTIVE_IMPORT_STATUSES.includes(dataImport?.status);
+
+export const isIntercomImport = dataImport =>
+ dataImport?.data_type === 'intercom' &&
+ dataImport?.source_provider === 'intercom';
+
+export const isActiveIntercomImport = dataImport =>
+ isIntercomImport(dataImport) && isActiveImport(dataImport);
+
+export const isAbandonableImport = dataImport =>
+ isActiveIntercomImport(dataImport);
+
+export const importedCount = dataImport => {
+ if (!isIntercomImport(dataImport)) {
+ return Number(dataImport?.processed_records || 0);
+ }
+
+ return ['contacts', 'conversations', 'messages'].reduce(
+ (total, key) => total + Number(dataImport?.stats?.[key]?.imported || 0),
+ 0
+ );
+};
+
+export const importStageKey = dataImport => {
+ if (!dataImport) return 'unknown';
+
+ if (dataImport.status === 'completed') return 'completed';
+ if (dataImport.status === 'completed_with_errors') {
+ return 'completed_with_errors';
+ }
+ if (dataImport.status === 'failed') return 'failed';
+ if (dataImport.status === 'abandoned') return 'abandoned';
+ if (dataImport.status === 'pending') return 'queued';
+
+ const importTypes = dataImport.import_types?.length
+ ? dataImport.import_types
+ : [dataImport.data_type];
+ const cursor = dataImport.cursor || {};
+
+ if (importTypes.includes('contacts') && !cursor.contacts?.completed) {
+ return 'contacts';
+ }
+
+ if (
+ importTypes.includes('conversations') &&
+ !cursor.conversations?.completed
+ ) {
+ return 'conversations';
+ }
+
+ return 'finalizing';
+};
+
+export const formatStatus = value => value?.replaceAll('_', ' ') || '-';
+
+export const sourceObjectLabel = record =>
+ [record.source_object_type, record.source_object_id]
+ .filter(Boolean)
+ .join(': ') || '-';
+
+export const formatDate = value => {
+ if (!value) return '-';
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(new Date(value));
+};
+
+const STATUS_DOT_CLASS = {
+ pending: 'bg-n-amber-9',
+ processing: 'bg-n-blue-9',
+ completed: 'bg-n-teal-9',
+ completed_with_errors: 'bg-n-amber-9',
+ failed: 'bg-n-ruby-9',
+ abandoned: 'bg-n-slate-9',
+};
+
+export const statusDotClass = status =>
+ STATUS_DOT_CLASS[status] || 'bg-n-slate-9';
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/specs/importStatus.spec.js b/app/javascript/dashboard/routes/dashboard/settings/data/specs/importStatus.spec.js
new file mode 100644
index 000000000..dcfe7d9bd
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/specs/importStatus.spec.js
@@ -0,0 +1,92 @@
+import {
+ formatDate,
+ importedCount,
+ isActiveIntercomImport,
+ statusDotClass,
+} from '../importStatus';
+
+describe('importStatus', () => {
+ describe('isActiveIntercomImport', () => {
+ it('only treats pending or processing Intercom imports as active', () => {
+ expect(
+ isActiveIntercomImport({
+ data_type: 'intercom',
+ source_provider: 'intercom',
+ status: 'processing',
+ })
+ ).toBe(true);
+ expect(
+ isActiveIntercomImport({
+ data_type: 'contacts',
+ source_provider: null,
+ status: 'processing',
+ })
+ ).toBe(false);
+ expect(
+ isActiveIntercomImport({
+ data_type: 'intercom',
+ source_provider: 'intercom',
+ status: 'completed',
+ })
+ ).toBe(false);
+ });
+ });
+
+ describe('importedCount', () => {
+ it('sums Intercom imported stats', () => {
+ expect(
+ importedCount({
+ data_type: 'intercom',
+ source_provider: 'intercom',
+ processed_records: 20,
+ stats: {
+ contacts: { imported: 2 },
+ conversations: { imported: 3 },
+ messages: { imported: 10 },
+ },
+ })
+ ).toBe(15);
+ });
+
+ it('uses processed records for legacy imports', () => {
+ expect(
+ importedCount({
+ data_type: 'contacts',
+ source_provider: null,
+ processed_records: 7,
+ stats: {},
+ })
+ ).toBe(7);
+ });
+ });
+
+ describe('statusDotClass', () => {
+ it('maps each status to its dot color class', () => {
+ expect(statusDotClass('pending')).toBe('bg-n-amber-9');
+ expect(statusDotClass('processing')).toBe('bg-n-blue-9');
+ expect(statusDotClass('completed')).toBe('bg-n-teal-9');
+ expect(statusDotClass('completed_with_errors')).toBe('bg-n-amber-9');
+ expect(statusDotClass('failed')).toBe('bg-n-ruby-9');
+ expect(statusDotClass('abandoned')).toBe('bg-n-slate-9');
+ });
+
+ it('falls back to slate for unknown or missing status', () => {
+ expect(statusDotClass('unknown')).toBe('bg-n-slate-9');
+ expect(statusDotClass(undefined)).toBe('bg-n-slate-9');
+ });
+ });
+
+ describe('formatDate', () => {
+ it('returns a dash for empty values', () => {
+ expect(formatDate(null)).toBe('-');
+ expect(formatDate('')).toBe('-');
+ expect(formatDate(undefined)).toBe('-');
+ });
+
+ it('formats a valid date into a readable string', () => {
+ const formatted = formatDate('2026-07-10T18:09:00Z');
+ expect(formatted).not.toBe('-');
+ expect(formatted).toContain('2026');
+ });
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/specs/pollingLifecycle.spec.js b/app/javascript/dashboard/routes/dashboard/settings/data/specs/pollingLifecycle.spec.js
new file mode 100644
index 000000000..7e74bd565
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/specs/pollingLifecycle.spec.js
@@ -0,0 +1,121 @@
+import { flushPromises, mount } from '@vue/test-utils';
+import { KeepAlive, defineComponent, h, nextTick, ref } from 'vue';
+import DataImportsAPI from 'dashboard/api/dataImports';
+import Index from '../Index.vue';
+import Show from '../Show.vue';
+
+vi.mock('dashboard/api/dataImports', () => ({
+ default: {
+ get: vi.fn(),
+ show: vi.fn(),
+ },
+}));
+
+vi.mock('dashboard/composables/store', () => ({
+ useStoreGetters: () => ({ getCurrentAccountId: { value: 1 } }),
+}));
+
+vi.mock('dashboard/composables', () => ({
+ useAlert: vi.fn(),
+}));
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({ t: key => key }),
+}));
+
+vi.mock('vue-router', async importOriginal => ({
+ ...(await importOriginal()),
+ useRoute: () => ({ params: { dataImportId: 1 } }),
+ useRouter: () => ({ push: vi.fn() }),
+}));
+
+const deferredRequest = () => {
+ let resolve;
+ const promise = new Promise(resolvePromise => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+};
+
+const mountKeptAlive = component => {
+ const Host = defineComponent({
+ setup() {
+ const visible = ref(true);
+ return { visible };
+ },
+ render() {
+ return h(KeepAlive, null, {
+ default: () => (this.visible ? h(component) : null),
+ });
+ },
+ });
+
+ return mount(Host, {
+ global: {
+ stubs: {
+ SettingsLayout: true,
+ BaseSettingsHeader: true,
+ Button: true,
+ Icon: true,
+ TabBar: true,
+ NewImportDialog: true,
+ ImportDetailHeader: true,
+ ImportSummaryTiles: true,
+ ImportProgress: true,
+ ImportErrorsSection: true,
+ ImportSkipLogsSection: true,
+ },
+ mocks: {
+ $t: key => key,
+ },
+ },
+ });
+};
+
+describe('data import polling lifecycle', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.clearAllMocks();
+ });
+
+ it('does not start list polling after the page deactivates', async () => {
+ const request = deferredRequest();
+ DataImportsAPI.get.mockReturnValue(request.promise);
+ const wrapper = mountKeptAlive(Index);
+ await nextTick();
+
+ wrapper.vm.visible = false;
+ await nextTick();
+ request.resolve({ data: { payload: [{ status: 'processing' }] } });
+ await flushPromises();
+ await vi.advanceTimersByTimeAsync(5000);
+
+ expect(DataImportsAPI.get).toHaveBeenCalledTimes(1);
+ wrapper.unmount();
+ });
+
+ it('does not start detail polling after the page deactivates', async () => {
+ const request = deferredRequest();
+ DataImportsAPI.show.mockReturnValue(request.promise);
+ const wrapper = mountKeptAlive(Show);
+ await nextTick();
+
+ wrapper.vm.visible = false;
+ await nextTick();
+ request.resolve({
+ data: {
+ status: 'processing',
+ skip_logs_filters: {},
+ },
+ });
+ await flushPromises();
+ await vi.advanceTimersByTimeAsync(5000);
+
+ expect(DataImportsAPI.show).toHaveBeenCalledTimes(1);
+ wrapper.unmount();
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue
index 884c198c4..0026cd8a9 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue
@@ -1,5 +1,5 @@
'); + expect(formattedMessage).toContain(''); + }); }); describe('#sanitize', () => { diff --git a/app/javascript/survey/i18n/index.js b/app/javascript/survey/i18n/index.js index 1a9ea1a59..908d3e628 100644 --- a/app/javascript/survey/i18n/index.js +++ b/app/javascript/survey/i18n/index.js @@ -35,6 +35,7 @@ import ta from './locale/ta.json'; import th from './locale/th.json'; import tr from './locale/tr.json'; import uk from './locale/uk.json'; +import uz from './locale/uz.json'; import vi from './locale/vi.json'; import zh_CN from './locale/zh_CN.json'; import zh_TW from './locale/zh_TW.json'; @@ -77,6 +78,7 @@ export default { th, tr, uk, + uz, vi, zh_CN, zh_TW, diff --git a/app/javascript/survey/i18n/locale/uz.json b/app/javascript/survey/i18n/locale/uz.json new file mode 100644 index 000000000..2d8359d79 --- /dev/null +++ b/app/javascript/survey/i18n/locale/uz.json @@ -0,0 +1,19 @@ +{ + "SURVEY": { + "DESCRIPTION": "Hurmatli mijoz 👋, {inboxName} bilan bo'lgan suhbatingiz haqida fikr bildirish uchun bir necha daqiqa vaqt ajrating.", + "RATING": { + "LABEL": "Suhbatingizni baholang", + "SUCCESS_MESSAGE": "Baho berganingiz uchun rahmat" + }, + "FEEDBACK": { + "LABEL": "Bildirmoqchi bo'lgan fikr-mulohazalaringiz bormi?", + "PLACEHOLDER": "Fikringiz (ixtiyoriy)", + "BUTTON_TEXT": "Fikrni yuborish" + }, + "API": { + "SUCCESS_MESSAGE": "So'rovnoma muvaffaqiyatli yangilandi", + "ERROR_MESSAGE": "Woot serveriga ulanib bo'lmadi, iltimos, keyinroq qayta urinib ko'ring" + } + }, + "POWERED_BY": "Chatwoot yordamida ishlaydi" +} diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue index 19a33b8a4..7637ffc2f 100755 --- a/app/javascript/widget/App.vue +++ b/app/javascript/widget/App.vue @@ -66,6 +66,9 @@ export default { ? getLanguageDirection(this.$root.$i18n.locale) : false; }, + isUnreadOrCampaignView() { + return ['unread-messages', 'campaigns'].includes(this.$route.name); + }, }, watch: { activeCampaign() { @@ -374,6 +377,7 @@ export default { 'is-widget-right': isRightAligned, 'is-bubble-hidden': hideMessageBubble, 'is-flat-design': isWidgetStyleFlat, + 'bg-n-slate-2 dark:bg-n-solid-1': !isUnreadOrCampaignView, dark: prefersDarkMode, }" > diff --git a/app/javascript/widget/api/endPoints.js b/app/javascript/widget/api/endPoints.js index 713de56f1..b1c76e94f 100755 --- a/app/javascript/widget/api/endPoints.js +++ b/app/javascript/widget/api/endPoints.js @@ -11,6 +11,7 @@ const createConversation = params => { name: params.fullName, email: params.emailAddress, phone_number: params.phoneNumber, + custom_attributes: params.contactCustomAttributes, }, message: { content: params.message, diff --git a/app/javascript/widget/api/specs/endPoints.spec.js b/app/javascript/widget/api/specs/endPoints.spec.js index b95b2f659..cf7f2486b 100644 --- a/app/javascript/widget/api/specs/endPoints.spec.js +++ b/app/javascript/widget/api/specs/endPoints.spec.js @@ -32,6 +32,50 @@ describe('#sendMessage', () => { }); }); +describe('#createConversation', () => { + it('includes contact custom attributes in the payload', () => { + const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({ + toString: () => 'mock date', + })); + vi.spyOn(window, 'location', 'get').mockReturnValue({ + ...window.location, + search: '?param=1', + }); + + window.WOOT_WIDGET = { + $root: { $i18n: { locale: 'ar' } }, + }; + + const result = endPoints.createConversation({ + fullName: 'John', + emailAddress: 'john@example.com', + phoneNumber: '+919745313456', + message: 'hey', + customAttributes: { order_id: '12345' }, + contactCustomAttributes: { cpf: '123.456.789-09' }, + }); + + expect(result).toEqual({ + url: `/api/v1/widget/conversations?param=1&locale=ar`, + params: { + contact: { + name: 'John', + email: 'john@example.com', + phone_number: '+919745313456', + custom_attributes: { cpf: '123.456.789-09' }, + }, + message: { + content: 'hey', + timestamp: 'mock date', + referer_url: '', + }, + custom_attributes: { order_id: '12345' }, + }, + }); + spy.mockRestore(); + }); +}); + describe('#sendMessage with pending metadata', () => { it('includes custom_attributes and labels in payload', () => { const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({ diff --git a/app/javascript/widget/i18n/index.js b/app/javascript/widget/i18n/index.js index 93a371391..ee7d355cf 100644 --- a/app/javascript/widget/i18n/index.js +++ b/app/javascript/widget/i18n/index.js @@ -35,6 +35,7 @@ import ta from './locale/ta.json'; import th from './locale/th.json'; import tr from './locale/tr.json'; import uk from './locale/uk.json'; +import uz from './locale/uz.json'; import vi from './locale/vi.json'; import zh_CN from './locale/zh_CN.json'; import zh_TW from './locale/zh_TW.json'; @@ -77,6 +78,7 @@ export default { th, tr, uk, + uz, vi, zh_CN, zh_TW, diff --git a/app/javascript/widget/i18n/locale/am.json b/app/javascript/widget/i18n/locale/am.json index c3d6ddfc1..82271f4de 100644 --- a/app/javascript/widget/i18n/locale/am.json +++ b/app/javascript/widget/i18n/locale/am.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/ar.json b/app/javascript/widget/i18n/locale/ar.json index 1424b7418..9d7e7daa1 100644 --- a/app/javascript/widget/i18n/locale/ar.json +++ b/app/javascript/widget/i18n/locale/ar.json @@ -106,6 +106,11 @@ "NOT_FOUND": "لا يوجد إيموجي يطابق بحثك", "ARIA_LABEL": "أداة اختيار الايموجي" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "لا يوجد إيموجي يطابق بحثك" + }, "CSAT": { "TITLE": "قيم محادثتك", "SUBMITTED_TITLE": "شكرا لك على تقييم المحادثة", diff --git a/app/javascript/widget/i18n/locale/az.json b/app/javascript/widget/i18n/locale/az.json index c3d6ddfc1..82271f4de 100644 --- a/app/javascript/widget/i18n/locale/az.json +++ b/app/javascript/widget/i18n/locale/az.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/bg.json b/app/javascript/widget/i18n/locale/bg.json index 369caa46a..ccaf944f5 100644 --- a/app/javascript/widget/i18n/locale/bg.json +++ b/app/javascript/widget/i18n/locale/bg.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Няма намерени емотикони", "ARIA_LABEL": "Избор на емотикони" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Оценете този разговор", "SUBMITTED_TITLE": "Благодарим ви, че оценихте разговора", diff --git a/app/javascript/widget/i18n/locale/bn.json b/app/javascript/widget/i18n/locale/bn.json index c3d6ddfc1..82271f4de 100644 --- a/app/javascript/widget/i18n/locale/bn.json +++ b/app/javascript/widget/i18n/locale/bn.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/ca.json b/app/javascript/widget/i18n/locale/ca.json index ce3b99da7..4553c4c6b 100644 --- a/app/javascript/widget/i18n/locale/ca.json +++ b/app/javascript/widget/i18n/locale/ca.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Cap emoji coincideix amb la teva cerca", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Cap emoji coincideix amb la teva cerca" + }, "CSAT": { "TITLE": "Valora la teva conversa", "SUBMITTED_TITLE": "Gràcies per enviar la qualificació", diff --git a/app/javascript/widget/i18n/locale/cs.json b/app/javascript/widget/i18n/locale/cs.json index 61bebf777..e2aa28472 100644 --- a/app/javascript/widget/i18n/locale/cs.json +++ b/app/javascript/widget/i18n/locale/cs.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Žádné emoji neodpovídají vašemu hledání", "ARIA_LABEL": "Výběr emoji" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Ohodnoťte svou konverzaci", "SUBMITTED_TITLE": "Děkujeme Vám za odeslání hodnocení", diff --git a/app/javascript/widget/i18n/locale/da.json b/app/javascript/widget/i18n/locale/da.json index ef7adfc51..b663ee089 100644 --- a/app/javascript/widget/i18n/locale/da.json +++ b/app/javascript/widget/i18n/locale/da.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Ingen emoji matcher din søgning", "ARIA_LABEL": "Emoji vælger" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Ingen emoji matcher din søgning" + }, "CSAT": { "TITLE": "Bedøm din samtale", "SUBMITTED_TITLE": "Tak for din bedømmelse", diff --git a/app/javascript/widget/i18n/locale/de.json b/app/javascript/widget/i18n/locale/de.json index fd6de1f2e..b51f879af 100644 --- a/app/javascript/widget/i18n/locale/de.json +++ b/app/javascript/widget/i18n/locale/de.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Kein Emoji entspricht Ihrer Suche", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Kein Emoji entspricht deiner Suche" + }, "CSAT": { "TITLE": "Bewerten Sie Ihre Konversation", "SUBMITTED_TITLE": "Danke, dass Sie die Bewertung eingereicht haben", diff --git a/app/javascript/widget/i18n/locale/el.json b/app/javascript/widget/i18n/locale/el.json index 64bb71e74..c3613fe58 100644 --- a/app/javascript/widget/i18n/locale/el.json +++ b/app/javascript/widget/i18n/locale/el.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Κανένα emoji δεν ταιριάζει με την αναζήτησή σας", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Κανένα emoji δεν ταιριάζει με την αναζήτησή σας" + }, "CSAT": { "TITLE": "Αξιολογήστε τη συνομιλία σας", "SUBMITTED_TITLE": "Ευχαριστούμε για την υποβολή της αξιολόγησης", diff --git a/app/javascript/widget/i18n/locale/es.json b/app/javascript/widget/i18n/locale/es.json index 3ced9aeab..8119a674a 100644 --- a/app/javascript/widget/i18n/locale/es.json +++ b/app/javascript/widget/i18n/locale/es.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Ningún emoji coincide con tu búsqueda", "ARIA_LABEL": "Selector de emoji" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Buscar emoji…", + "FREQUENTLY_USED": "Usado con frecuencia", + "NO_EMOJI": "Ningún emoji coincide con tu búsqueda" + }, "CSAT": { "TITLE": "Califica tu conversación", "SUBMITTED_TITLE": "Gracias por enviar la valoración", diff --git a/app/javascript/widget/i18n/locale/et.json b/app/javascript/widget/i18n/locale/et.json index e4a1f135f..e856e3f89 100644 --- a/app/javascript/widget/i18n/locale/et.json +++ b/app/javascript/widget/i18n/locale/et.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Ühtegi emotikoni ei leitud", "ARIA_LABEL": "Emotikonide valija" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Hinda oma vestlust", "SUBMITTED_TITLE": "Täname hinnangu eest", diff --git a/app/javascript/widget/i18n/locale/fa.json b/app/javascript/widget/i18n/locale/fa.json index c4d1bcc1c..17eac8c8e 100644 --- a/app/javascript/widget/i18n/locale/fa.json +++ b/app/javascript/widget/i18n/locale/fa.json @@ -106,6 +106,11 @@ "NOT_FOUND": "هیچ اموجی با جستجوی شما مطابقت ندارد", "ARIA_LABEL": "انتخاب اموجی" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "هیچ اموجی با جستجوی شما مطابقت ندارد" + }, "CSAT": { "TITLE": "به گفتگوی خود امتیاز دهید", "SUBMITTED_TITLE": "با تشکر از شما برای ثبت امتیاز", diff --git a/app/javascript/widget/i18n/locale/fi.json b/app/javascript/widget/i18n/locale/fi.json index b11170020..c7655af29 100644 --- a/app/javascript/widget/i18n/locale/fi.json +++ b/app/javascript/widget/i18n/locale/fi.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Yksikään emoji ei vastaa hakuasi", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Anna arvio palvelusta", "SUBMITTED_TITLE": "Kiitos, että lähetit arvion", diff --git a/app/javascript/widget/i18n/locale/fr.json b/app/javascript/widget/i18n/locale/fr.json index 90275e02a..9f6a649f1 100644 --- a/app/javascript/widget/i18n/locale/fr.json +++ b/app/javascript/widget/i18n/locale/fr.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Aucun émoji ne correspond à votre recherche", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Aucun émoji ne correspond à votre recherche" + }, "CSAT": { "TITLE": "Évaluer votre conversation", "SUBMITTED_TITLE": "Merci d'avoir soumis votre évaluation", diff --git a/app/javascript/widget/i18n/locale/he.json b/app/javascript/widget/i18n/locale/he.json index 70ea66e83..0fdfa0fd3 100644 --- a/app/javascript/widget/i18n/locale/he.json +++ b/app/javascript/widget/i18n/locale/he.json @@ -106,6 +106,11 @@ "NOT_FOUND": "אין אמוג'י שתואם את החיפוש שלך", "ARIA_LABEL": "בוחר אימוג'י" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "אין אמוג'י שתואם את החיפוש שלך" + }, "CSAT": { "TITLE": "דרג את השיחה שלך", "SUBMITTED_TITLE": "תודה על שליחת הדירוג", diff --git a/app/javascript/widget/i18n/locale/hi.json b/app/javascript/widget/i18n/locale/hi.json index c3d6ddfc1..82271f4de 100644 --- a/app/javascript/widget/i18n/locale/hi.json +++ b/app/javascript/widget/i18n/locale/hi.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/hr.json b/app/javascript/widget/i18n/locale/hr.json index 1ae76f16f..f10e4eef4 100644 --- a/app/javascript/widget/i18n/locale/hr.json +++ b/app/javascript/widget/i18n/locale/hr.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Nema emojija koji odgovaraju vašoj pretrazi", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Ocijenite vaš razgovor", "SUBMITTED_TITLE": "Hvala vam na ocjeni", diff --git a/app/javascript/widget/i18n/locale/hu.json b/app/javascript/widget/i18n/locale/hu.json index 5ea7c095a..2bca22079 100644 --- a/app/javascript/widget/i18n/locale/hu.json +++ b/app/javascript/widget/i18n/locale/hu.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Nem található emoji", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Nem található emoji" + }, "CSAT": { "TITLE": "Értékeld a beszélgetést", "SUBMITTED_TITLE": "Köszönjük, hogy elküldted az értékelést", diff --git a/app/javascript/widget/i18n/locale/hy.json b/app/javascript/widget/i18n/locale/hy.json index c3d6ddfc1..82271f4de 100644 --- a/app/javascript/widget/i18n/locale/hy.json +++ b/app/javascript/widget/i18n/locale/hy.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/id.json b/app/javascript/widget/i18n/locale/id.json index 59f86956f..d65558010 100644 --- a/app/javascript/widget/i18n/locale/id.json +++ b/app/javascript/widget/i18n/locale/id.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Tidak ada emoji yang cocok dengan penelusuran anda", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Tidak ditemukan emoji yang sesuai dengan pencarian Anda" + }, "CSAT": { "TITLE": "Nilai percakapan anda", "SUBMITTED_TITLE": "Terima kasih telah mengirimkan penilaian", diff --git a/app/javascript/widget/i18n/locale/is.json b/app/javascript/widget/i18n/locale/is.json index 0ab53118a..14d5e585b 100644 --- a/app/javascript/widget/i18n/locale/is.json +++ b/app/javascript/widget/i18n/locale/is.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Enginn emoji fannst", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Gefðu samtalinu einkunn", "SUBMITTED_TITLE": "Takk fyrir að gefa samtalinu einkunn", diff --git a/app/javascript/widget/i18n/locale/it.json b/app/javascript/widget/i18n/locale/it.json index d5ec381d2..5d3c37acd 100644 --- a/app/javascript/widget/i18n/locale/it.json +++ b/app/javascript/widget/i18n/locale/it.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Nessuna emoji corrisponde alla tua ricerca", "ARIA_LABEL": "Selettore emoji" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Nessuna emoji corrisponde alla tua ricerca" + }, "CSAT": { "TITLE": "Valuta la conversazione", "SUBMITTED_TITLE": "Grazie per aver inviato la valutazione", diff --git a/app/javascript/widget/i18n/locale/ja.json b/app/javascript/widget/i18n/locale/ja.json index b8300fbab..a915271f9 100644 --- a/app/javascript/widget/i18n/locale/ja.json +++ b/app/javascript/widget/i18n/locale/ja.json @@ -106,6 +106,11 @@ "NOT_FOUND": "検索条件に一致する絵文字がありません", "ARIA_LABEL": "絵文字選択" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "検索条件に一致する絵文字が見つかりません" + }, "CSAT": { "TITLE": "会話の評価", "SUBMITTED_TITLE": "評価を送信いただきありがとうございます", diff --git a/app/javascript/widget/i18n/locale/ka.json b/app/javascript/widget/i18n/locale/ka.json index ce5f10c07..e9ec1ea08 100644 --- a/app/javascript/widget/i18n/locale/ka.json +++ b/app/javascript/widget/i18n/locale/ka.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/ko.json b/app/javascript/widget/i18n/locale/ko.json index d9f213da4..379cba948 100644 --- a/app/javascript/widget/i18n/locale/ko.json +++ b/app/javascript/widget/i18n/locale/ko.json @@ -106,6 +106,11 @@ "NOT_FOUND": "검색과 일치하는 이모지 없음", "ARIA_LABEL": "이모지 선택기" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "검색과 일치하는 이모지가 없습니다" + }, "CSAT": { "TITLE": "대화를 평가해주세요.", "SUBMITTED_TITLE": "평가해주셔서 감사합니다!", diff --git a/app/javascript/widget/i18n/locale/lt.json b/app/javascript/widget/i18n/locale/lt.json index a11db6919..5d07721e5 100644 --- a/app/javascript/widget/i18n/locale/lt.json +++ b/app/javascript/widget/i18n/locale/lt.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Jūsų paieška neatitinka jokių jaustukų", "ARIA_LABEL": "Jaustukų pasirinkimas" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Jūsų paieška neatitinka jokių emodži" + }, "CSAT": { "TITLE": "Įvertinkite savo pokalbį", "SUBMITTED_TITLE": "Dėkojame, kad pateikėte įvertinimą", diff --git a/app/javascript/widget/i18n/locale/lv.json b/app/javascript/widget/i18n/locale/lv.json index 702fb041f..8d5b93417 100644 --- a/app/javascript/widget/i18n/locale/lv.json +++ b/app/javascript/widget/i18n/locale/lv.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Neviena emocijzīme neatbilst jūsu meklēšanas vaicājumam", "ARIA_LABEL": "Emocijzīmju atlasītājs" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Neviena emocijzīme neatbilst jūsu meklēšanas vaicājumam" + }, "CSAT": { "TITLE": "Novērtējiet Jūsu sarunu", "SUBMITTED_TITLE": "Paldies, ka iesniedzāt novērtējumu", diff --git a/app/javascript/widget/i18n/locale/ml.json b/app/javascript/widget/i18n/locale/ml.json index 96a650cb4..5fecfb403 100644 --- a/app/javascript/widget/i18n/locale/ml.json +++ b/app/javascript/widget/i18n/locale/ml.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "നിങ്ങളുടെ സംഭാഷണം റേറ്റുചെയ്യുക", "SUBMITTED_TITLE": "റേറ്റിംഗ് സമർപ്പിച്ചതിന് നന്ദി", diff --git a/app/javascript/widget/i18n/locale/ms.json b/app/javascript/widget/i18n/locale/ms.json index d813e4eb1..3fd8aa653 100644 --- a/app/javascript/widget/i18n/locale/ms.json +++ b/app/javascript/widget/i18n/locale/ms.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/ne.json b/app/javascript/widget/i18n/locale/ne.json index 20120afd9..f1959ec1e 100644 --- a/app/javascript/widget/i18n/locale/ne.json +++ b/app/javascript/widget/i18n/locale/ne.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/nl.json b/app/javascript/widget/i18n/locale/nl.json index 2f123f80a..271d9feed 100644 --- a/app/javascript/widget/i18n/locale/nl.json +++ b/app/javascript/widget/i18n/locale/nl.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Er zijn geen overeenkomende emoji's gevonden", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Er zijn geen overeenkomende emoji's gevonden" + }, "CSAT": { "TITLE": "Beoordeel uw gesprek", "SUBMITTED_TITLE": "Bedankt voor het indienen van een beoordeling", diff --git a/app/javascript/widget/i18n/locale/no.json b/app/javascript/widget/i18n/locale/no.json index 9034df45b..92742d443 100644 --- a/app/javascript/widget/i18n/locale/no.json +++ b/app/javascript/widget/i18n/locale/no.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Ingen emojier samsvarer søket ditt", "ARIA_LABEL": "Emoji velger" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Vurder samtalen din", "SUBMITTED_TITLE": "Takk for at du ga din vurdering", diff --git a/app/javascript/widget/i18n/locale/pl.json b/app/javascript/widget/i18n/locale/pl.json index c2095692f..7677a5ea2 100644 --- a/app/javascript/widget/i18n/locale/pl.json +++ b/app/javascript/widget/i18n/locale/pl.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Nie znaleziono emoji pasującego do wyszukiwania", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Nie znaleziono emoji pasującego do wyszukiwania" + }, "CSAT": { "TITLE": "Oceń udzielone Ci wsparcie", "SUBMITTED_TITLE": "Dziękujemy za przesłanie oceny", diff --git a/app/javascript/widget/i18n/locale/pt.json b/app/javascript/widget/i18n/locale/pt.json index 300ffb59f..0dfebb81e 100644 --- a/app/javascript/widget/i18n/locale/pt.json +++ b/app/javascript/widget/i18n/locale/pt.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Nenhum emoji corresponde à sua pesquisa", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Nenhum emoji corresponde à sua pesquisa" + }, "CSAT": { "TITLE": "Avalie a sua conversa", "SUBMITTED_TITLE": "Obrigado pela sua avaliação", diff --git a/app/javascript/widget/i18n/locale/pt_BR.json b/app/javascript/widget/i18n/locale/pt_BR.json index c5c85a251..7c9422df9 100644 --- a/app/javascript/widget/i18n/locale/pt_BR.json +++ b/app/javascript/widget/i18n/locale/pt_BR.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Nenhum emoji corresponde à sua pesquisa", "ARIA_LABEL": "Seletor de emoji" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Pesquisar emoji…", + "FREQUENTLY_USED": "Usados com frequência", + "NO_EMOJI": "Nenhum emoji corresponde à sua pesquisa" + }, "CSAT": { "TITLE": "Avalie sua conversa", "SUBMITTED_TITLE": "Obrigado por enviar a classificação", diff --git a/app/javascript/widget/i18n/locale/ro.json b/app/javascript/widget/i18n/locale/ro.json index cd77700b8..bfd5ca849 100644 --- a/app/javascript/widget/i18n/locale/ro.json +++ b/app/javascript/widget/i18n/locale/ro.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Niciun emoji nu corespunde căutării tale", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Niciun emoji nu corespunde căutării tale" + }, "CSAT": { "TITLE": "Evaluează conversația ta", "SUBMITTED_TITLE": "Vă mulțumim pentru trimiterea de rating", diff --git a/app/javascript/widget/i18n/locale/ru.json b/app/javascript/widget/i18n/locale/ru.json index a2d1005c1..397088273 100644 --- a/app/javascript/widget/i18n/locale/ru.json +++ b/app/javascript/widget/i18n/locale/ru.json @@ -106,6 +106,11 @@ "NOT_FOUND": "По Вашему запросу не было найдено эмодзи", "ARIA_LABEL": "Выбор эмодзи" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Эмодзи не соответствуют вашему запросу" + }, "CSAT": { "TITLE": "Оцените разговор", "SUBMITTED_TITLE": "Спасибо за оценку", diff --git a/app/javascript/widget/i18n/locale/sh.json b/app/javascript/widget/i18n/locale/sh.json index c3d6ddfc1..82271f4de 100644 --- a/app/javascript/widget/i18n/locale/sh.json +++ b/app/javascript/widget/i18n/locale/sh.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/sk.json b/app/javascript/widget/i18n/locale/sk.json index b7ac2a763..49865cd03 100644 --- a/app/javascript/widget/i18n/locale/sk.json +++ b/app/javascript/widget/i18n/locale/sk.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Ohodnoťte konverzáciu", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/sl.json b/app/javascript/widget/i18n/locale/sl.json index 0c95f1268..b06eaeeaf 100644 --- a/app/javascript/widget/i18n/locale/sl.json +++ b/app/javascript/widget/i18n/locale/sl.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Noben emoji ne ustreza vašemu iskanju", "ARIA_LABEL": "Iskalnik emoji-jev" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Z vašim iskanjem se ne ujema noben emoji" + }, "CSAT": { "TITLE": "Ocenite svoj pogovor", "SUBMITTED_TITLE": "Hvala za oddano oceno", diff --git a/app/javascript/widget/i18n/locale/sq.json b/app/javascript/widget/i18n/locale/sq.json index 3efbe543e..299cf6999 100644 --- a/app/javascript/widget/i18n/locale/sq.json +++ b/app/javascript/widget/i18n/locale/sq.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/sr.json b/app/javascript/widget/i18n/locale/sr.json index c0eae2c59..b0e1e8205 100644 --- a/app/javascript/widget/i18n/locale/sr.json +++ b/app/javascript/widget/i18n/locale/sr.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Ocenite razgovor", "SUBMITTED_TITLE": "Hvala vam na dostavljenoj oceni", diff --git a/app/javascript/widget/i18n/locale/sv.json b/app/javascript/widget/i18n/locale/sv.json index 4d6e1723c..3ee701e1d 100644 --- a/app/javascript/widget/i18n/locale/sv.json +++ b/app/javascript/widget/i18n/locale/sv.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Ingen emoji matchar din sökning", "ARIA_LABEL": "Emoji-väljare" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Ingen emoji matchar din sökning" + }, "CSAT": { "TITLE": "Betygsätt din konversation", "SUBMITTED_TITLE": "Tack för att du lämnat in omdömet", diff --git a/app/javascript/widget/i18n/locale/ta.json b/app/javascript/widget/i18n/locale/ta.json index 91ccddf96..922f50d91 100644 --- a/app/javascript/widget/i18n/locale/ta.json +++ b/app/javascript/widget/i18n/locale/ta.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/th.json b/app/javascript/widget/i18n/locale/th.json index fcfed1fde..1c4781380 100644 --- a/app/javascript/widget/i18n/locale/th.json +++ b/app/javascript/widget/i18n/locale/th.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "ให้คะแนนการสนทนาครั้งนี้", "SUBMITTED_TITLE": "ขอบคุณที่ร่วมมือให้คะแนนการสนทนากับเรา", diff --git a/app/javascript/widget/i18n/locale/tl.json b/app/javascript/widget/i18n/locale/tl.json index c3d6ddfc1..82271f4de 100644 --- a/app/javascript/widget/i18n/locale/tl.json +++ b/app/javascript/widget/i18n/locale/tl.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/tr.json b/app/javascript/widget/i18n/locale/tr.json index 7c552eac5..d3f716523 100644 --- a/app/javascript/widget/i18n/locale/tr.json +++ b/app/javascript/widget/i18n/locale/tr.json @@ -106,6 +106,11 @@ "NOT_FOUND": "İfade bulunamadı", "ARIA_LABEL": "Emoji seçimi" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Aramanızla eşleşen emoji yok" + }, "CSAT": { "TITLE": "Görüşmenizi değerlendirin", "SUBMITTED_TITLE": "Değerlendirmeniz için teşekkürler", diff --git a/app/javascript/widget/i18n/locale/uk.json b/app/javascript/widget/i18n/locale/uk.json index d31df8ab0..4c028be58 100644 --- a/app/javascript/widget/i18n/locale/uk.json +++ b/app/javascript/widget/i18n/locale/uk.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Немає емодзі, що відповідають пошуковому запиту", "ARIA_LABEL": "Вибір емодзі" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "Немає смайликів, які відповідають вашому запиту" + }, "CSAT": { "TITLE": "Оцініть вашу бесіду", "SUBMITTED_TITLE": "Дякуємо за оцінку", diff --git a/app/javascript/widget/i18n/locale/ur.json b/app/javascript/widget/i18n/locale/ur.json index 7b03483a4..16cf1968f 100644 --- a/app/javascript/widget/i18n/locale/ur.json +++ b/app/javascript/widget/i18n/locale/ur.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/ur_IN.json b/app/javascript/widget/i18n/locale/ur_IN.json index c3d6ddfc1..82271f4de 100644 --- a/app/javascript/widget/i18n/locale/ur_IN.json +++ b/app/javascript/widget/i18n/locale/ur_IN.json @@ -106,6 +106,11 @@ "NOT_FOUND": "No emoji match your search", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Rate your conversation", "SUBMITTED_TITLE": "Thank you for submitting the rating", diff --git a/app/javascript/widget/i18n/locale/uz.json b/app/javascript/widget/i18n/locale/uz.json new file mode 100644 index 000000000..3e0b06a5d --- /dev/null +++ b/app/javascript/widget/i18n/locale/uz.json @@ -0,0 +1,158 @@ +{ + "COMPONENTS": { + "FILE_BUBBLE": { + "DOWNLOAD": "Yuklab olish", + "UPLOADING": "Yuklanmoqda..." + }, + "FORM_BUBBLE": { + "SUBMIT": "Yuborish" + }, + "MESSAGE_BUBBLE": { + "RETRY": "Xabarni qayta yuborish", + "ERROR_MESSAGE": "Yuborib bo'lmadi, qayta urinib ko'ring" + } + }, + "THUMBNAIL": { + "AUTHOR": { + "NOT_AVAILABLE": "Mavjud emas" + } + }, + "TEAM_AVAILABILITY": { + "ONLINE": "Biz onlaynmiz", + "OFFLINE": "Hozircha javob bera olmaymiz", + "BACK_AS_SOON_AS_POSSIBLE": "Imkon qadar tezroq qaytamiz" + }, + "REPLY_TIME": { + "IN_A_FEW_MINUTES": "Odatda bir necha daqiqada javob beramiz", + "IN_A_FEW_HOURS": "Odatda bir necha soatda javob beramiz", + "IN_A_DAY": "Odatda bir kun ichida javob beramiz", + "BACK_IN_HOURS": "{n} soatdan keyin onlayn bo'lamiz | {n} soatdan keyin onlayn bo'lamiz", + "BACK_IN_MINUTES": "{time} daqiqadan keyin onlayn bo'lamiz", + "BACK_AT_TIME": "Soat {time} da onlayn bo'lamiz", + "BACK_ON_DAY": "{day} kuni onlayn bo'lamiz", + "BACK_TOMORROW": "Ertaga onlayn bo'lamiz", + "BACK_IN_SOME_TIME": "Birozdan keyin onlayn bo'lamiz" + }, + "DAY_NAMES": { + "SUNDAY": "Yakshanba", + "MONDAY": "Dushanba", + "TUESDAY": "Seshanba", + "WEDNESDAY": "Chorshanba", + "THURSDAY": "Payshanba", + "FRIDAY": "Juma", + "SATURDAY": "Shanba" + }, + "START_CONVERSATION": "Suhbatni boshlash", + "END_CONVERSATION": "Suhbatni tugatish", + "CONTINUE_CONVERSATION": "Suhbatni davom ettirish", + "YOU": "Siz", + "START_NEW_CONVERSATION": "Yangi suhbatni boshlash", + "VIEW_UNREAD_MESSAGES": "Sizda o'qilmagan xabarlar bor", + "UNREAD_VIEW": { + "VIEW_MESSAGES_BUTTON": "Yangi xabarlarni ko'rish", + "CLOSE_MESSAGES_BUTTON": "Yopish", + "COMPANY_FROM": "tomonidan", + "BOT": "Bot" + }, + "BUBBLE": { + "LABEL": "Biz bilan suhbatlashing" + }, + "POWERED_BY": "Chatwoot yordamida ishlaydi", + "EMAIL_PLACEHOLDER": "Iltimos, emailingizni kiriting", + "CHAT_PLACEHOLDER": "Xabaringizni yozing", + "TODAY": "Bugun", + "YESTERDAY": "Kecha", + "PRE_CHAT_FORM": { + "FIELDS": { + "FULL_NAME": { + "LABEL": "To'liq ism", + "PLACEHOLDER": "Iltimos, to'liq ismingizni kiriting", + "REQUIRED_ERROR": "To'liq ism kiritilishi shart" + }, + "EMAIL_ADDRESS": { + "LABEL": "Email manzili", + "PLACEHOLDER": "Iltimos, email manzilingizni kiriting", + "REQUIRED_ERROR": "Email manzilini kiritish shart", + "VALID_ERROR": "Iltimos, to'g'ri email manzilini kiriting" + }, + "PHONE_NUMBER": { + "LABEL": "Telefon raqami", + "PLACEHOLDER": "Iltimos, telefon raqamingizni kiriting", + "REQUIRED_ERROR": "Telefon raqami kiritilishi shart", + "DIAL_CODE_VALID_ERROR": "Iltimos, mamlakat kodini tanlang", + "VALID_ERROR": "Iltimos, to'g'ri telefon raqamini kiriting", + "DROPDOWN_EMPTY": "Natija topilmadi", + "DROPDOWN_SEARCH": "Mamlakatni qidirish" + }, + "MESSAGE": { + "LABEL": "Xabar", + "PLACEHOLDER": "Iltimos, xabaringizni kiriting", + "ERROR": "Xabar juda qisqa" + } + }, + "CAMPAIGN_HEADER": "Suhbatni boshlashdan oldin ism va emailingizni kiriting", + "IS_REQUIRED": "kiritilishi shart", + "REQUIRED": "Majburiy", + "REGEX_ERROR": "Iltimos, to'g'ri ma'lumot kiriting" + }, + "FILE_SIZE_LIMIT": "Fayl {MAXIMUM_FILE_UPLOAD_SIZE} biriktirma chegarasidan oshib ketdi", + "CHAT_FORM": { + "INVALID": { + "FIELD": "Noto'g'ri maydon" + } + }, + "EMOJI": { + "PLACEHOLDER": "Emoji qidirish", + "NOT_FOUND": "Qidiruvga mos emoji topilmadi", + "ARIA_LABEL": "Emoji tanlagich" + }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Emojilarni qidirish…", + "FREQUENTLY_USED": "Ko'p ishlatiladiganlar", + "NO_EMOJI": "Qidiruvga mos emoji topilmadi" + }, + "CSAT": { + "TITLE": "Suhbatingizni baholang", + "SUBMITTED_TITLE": "Baho berganingiz uchun rahmat", + "PLACEHOLDER": "Batafsil aytib bering..." + }, + "EMAIL_TRANSCRIPT": { + "BUTTON_TEXT": "Suhbat tarixini so'rash", + "SEND_EMAIL_SUCCESS": "Suhbat tarixi muvaffaqiyatli yuborildi", + "SEND_EMAIL_ERROR": "Xatolik yuz berdi, qayta urinib ko'ring" + }, + "INTEGRATIONS": { + "DYTE": { + "CLICK_HERE_TO_JOIN": "Qo'shilish uchun shu yerni bosing", + "LEAVE_THE_ROOM": "Qo'ng'iroqni tark etish" + } + }, + "PORTAL": { + "POPULAR_ARTICLES": "Ommabop maqolalar", + "VIEW_ALL_ARTICLES": "Barcha maqolalarni ko'rish", + "IFRAME_LOAD_ERROR": "Maqolani yuklashda xatolik yuz berdi, sahifani yangilab qayta urinib ko'ring." + }, + "ATTACHMENTS": { + "image": { + "CONTENT": "Rasmli xabar" + }, + "audio": { + "CONTENT": "Audio xabar" + }, + "video": { + "CONTENT": "Video xabar" + }, + "file": { + "CONTENT": "Fayl biriktirmasi" + }, + "location": { + "CONTENT": "Joylashuv" + }, + "fallback": { + "CONTENT": "havola ulashdi" + } + }, + "FOOTER_REPLY_TO": { + "REPLY_TO": "Javob berilmoqda:" + } +} diff --git a/app/javascript/widget/i18n/locale/vi.json b/app/javascript/widget/i18n/locale/vi.json index 312883388..318316f3f 100644 --- a/app/javascript/widget/i18n/locale/vi.json +++ b/app/javascript/widget/i18n/locale/vi.json @@ -106,6 +106,11 @@ "NOT_FOUND": "Không có biểu tượng cảm xúc nào phù hợp với tìm kiếm của bạn", "ARIA_LABEL": "Emoji picker" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "No emoji match your search" + }, "CSAT": { "TITLE": "Đánh giá hội thoại", "SUBMITTED_TITLE": "Cảm ơn vì đã đánh giá", diff --git a/app/javascript/widget/i18n/locale/zh_CN.json b/app/javascript/widget/i18n/locale/zh_CN.json index dc4a1cebe..ac30579e8 100644 --- a/app/javascript/widget/i18n/locale/zh_CN.json +++ b/app/javascript/widget/i18n/locale/zh_CN.json @@ -106,6 +106,11 @@ "NOT_FOUND": "没有适合你的搜索结果", "ARIA_LABEL": "Emoji选择器" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "没有适合你的搜索结果" + }, "CSAT": { "TITLE": "评价您的对话", "SUBMITTED_TITLE": "感谢您提交评分", diff --git a/app/javascript/widget/i18n/locale/zh_TW.json b/app/javascript/widget/i18n/locale/zh_TW.json index bd401a8ca..bfc737d91 100644 --- a/app/javascript/widget/i18n/locale/zh_TW.json +++ b/app/javascript/widget/i18n/locale/zh_TW.json @@ -106,6 +106,11 @@ "NOT_FOUND": "查無相符的 emoji", "ARIA_LABEL": "表情選擇" }, + "EMOJI_ICON_PICKER": { + "SEARCH_EMOJI": "Search emoji…", + "FREQUENTLY_USED": "Frequently used", + "NO_EMOJI": "找不到符合的表情符號" + }, "CSAT": { "TITLE": "為此對話評分", "SUBMITTED_TITLE": "感謝您提交的評分", diff --git a/app/javascript/widget/views/ArticleViewer.vue b/app/javascript/widget/views/ArticleViewer.vue index 9289d0546..bc4cf775c 100644 --- a/app/javascript/widget/views/ArticleViewer.vue +++ b/app/javascript/widget/views/ArticleViewer.vue @@ -10,7 +10,7 @@ export default { -
+diff --git a/app/javascript/widget/views/PreChatForm.vue b/app/javascript/widget/views/PreChatForm.vue index 4872edbcd..5bbac0596 100644 --- a/app/javascript/widget/views/PreChatForm.vue +++ b/app/javascript/widget/views/PreChatForm.vue @@ -3,7 +3,6 @@ import { mapActions } from 'vuex'; import { useRouter } from 'vue-router'; import PreChatForm from '../components/PreChat/Form.vue'; import configMixin from '../mixins/configMixin'; -import { isEmptyObject } from 'widget/helpers/utils'; import { ON_CONVERSATION_CREATED } from '../constants/widgetBusEvents'; import { emitter } from 'shared/helpers/mitt'; @@ -42,6 +41,10 @@ export default { contactCustomAttributes, conversationCustomAttributes, }) { + // Contact custom attributes are sent within the same request that + // identifies the contact. A separate update call would race the contact + // merge on the server (matching email/phone) and write the values to + // the destroyed contact, silently losing them. if (activeCampaignId) { emitter.emit('execute-campaign', { campaignId: activeCampaignId, @@ -52,6 +55,7 @@ export default { email: emailAddress, name: fullName, phone_number: phoneNumber, + custom_attributes: contactCustomAttributes, }, }); } else { @@ -63,14 +67,9 @@ export default { message: message, phoneNumber: phoneNumber, customAttributes: conversationCustomAttributes, + contactCustomAttributes: contactCustomAttributes, }); } - if (!isEmptyObject(contactCustomAttributes)) { - this.$store.dispatch( - 'contacts/setCustomAttributes', - contactCustomAttributes - ); - } }, }, }; diff --git a/app/javascript/widget/views/specs/PreChatForm.spec.js b/app/javascript/widget/views/specs/PreChatForm.spec.js new file mode 100644 index 000000000..25bb16b53 --- /dev/null +++ b/app/javascript/widget/views/specs/PreChatForm.spec.js @@ -0,0 +1,89 @@ +import { shallowMount, flushPromises } from '@vue/test-utils'; +import { createStore } from 'vuex'; +import PreChatFormView from '../PreChatForm.vue'; + +global.chatwootWebChannel = { + preChatFormEnabled: true, + preChatFormOptions: { pre_chat_fields: [], pre_chat_message: '' }, +}; + +describe('PreChatForm view', () => { + let createConversation; + let setCustomAttributes; + let updateContact; + let store; + + beforeEach(() => { + createConversation = vi.fn(); + setCustomAttributes = vi.fn(); + updateContact = vi.fn(); + store = createStore({ + modules: { + conversation: { + namespaced: true, + actions: { createConversation, clearConversations: vi.fn() }, + }, + conversationAttributes: { + namespaced: true, + actions: { clearConversationAttributes: vi.fn() }, + }, + contacts: { + namespaced: true, + actions: { setCustomAttributes, update: updateContact }, + }, + }, + }); + }); + + const mountView = () => + shallowMount(PreChatFormView, { global: { plugins: [store] } }); + + it('sends contact custom attributes with the conversation create request', async () => { + const wrapper = mountView(); + wrapper.vm.onSubmit({ + fullName: 'John', + emailAddress: 'john@example.com', + message: 'hey', + contactCustomAttributes: { cpf: '123.456.789-09' }, + conversationCustomAttributes: { order_id: '12345' }, + }); + await flushPromises(); + + expect(createConversation).toHaveBeenCalledWith(expect.anything(), { + fullName: 'John', + emailAddress: 'john@example.com', + message: 'hey', + phoneNumber: undefined, + customAttributes: { order_id: '12345' }, + contactCustomAttributes: { cpf: '123.456.789-09' }, + }); + // attributes ride along in the create request itself; a separate call + // would race the contact merge on the server and write to a destroyed + // contact + expect(setCustomAttributes).not.toHaveBeenCalled(); + }); + + it('sends contact custom attributes along with the contact update for campaigns', async () => { + const wrapper = mountView(); + wrapper.vm.onSubmit({ + fullName: 'John', + emailAddress: 'john@example.com', + phoneNumber: null, + activeCampaignId: 42, + contactCustomAttributes: { cpf: '123.456.789-09' }, + conversationCustomAttributes: {}, + }); + await flushPromises(); + + expect(updateContact).toHaveBeenCalledWith(expect.anything(), { + user: { + email: 'john@example.com', + name: 'John', + phone_number: null, + custom_attributes: { cpf: '123.456.789-09' }, + }, + }); + expect(createConversation).not.toHaveBeenCalled(); + expect(setCustomAttributes).not.toHaveBeenCalled(); + }); +}); diff --git a/app/jobs/agents/destroy_job.rb b/app/jobs/agents/destroy_job.rb index 8596ca1cc..fe0276bc6 100644 --- a/app/jobs/agents/destroy_job.rb +++ b/app/jobs/agents/destroy_job.rb @@ -31,7 +31,11 @@ class Agents::DestroyJob < ApplicationJob def unassign_conversations(account, user) # rubocop:disable Rails/SkipsModelValidations - user.assigned_conversations.where(account: account).in_batches.update_all(assignee_id: nil) + unassigned_count = user.assigned_conversations.where(account: account).in_batches.update_all(assignee_id: nil) # rubocop:enable Rails/SkipsModelValidations + + return unless unassigned_count.positive? + + ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).conversation_changed! end end diff --git a/app/jobs/channels/whatsapp/templates_sync_scheduler_job.rb b/app/jobs/channels/whatsapp/templates_sync_scheduler_job.rb index 6a5e6cb2b..8cbc5a0ff 100644 --- a/app/jobs/channels/whatsapp/templates_sync_scheduler_job.rb +++ b/app/jobs/channels/whatsapp/templates_sync_scheduler_job.rb @@ -2,7 +2,9 @@ class Channels::Whatsapp::TemplatesSyncSchedulerJob < ApplicationJob queue_as :low def perform - Channel::Whatsapp.order(Arel.sql('message_templates_last_updated IS NULL DESC, message_templates_last_updated ASC')) + Channel::Whatsapp.joins(:account) + .merge(Account.active) + .order(Arel.sql('message_templates_last_updated IS NULL DESC, message_templates_last_updated ASC')) .where('message_templates_last_updated <= ? OR message_templates_last_updated IS NULL', 3.hours.ago) .limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT) .each do |channel| diff --git a/app/jobs/data_imports/intercom/base_job.rb b/app/jobs/data_imports/intercom/base_job.rb new file mode 100644 index 000000000..d4842c061 --- /dev/null +++ b/app/jobs/data_imports/intercom/base_job.rb @@ -0,0 +1,43 @@ +class DataImports::Intercom::BaseJob < ApplicationJob + queue_as :low + + retry_on DataImports::Intercom::Client::Error, wait: 1.minute, attempts: 3 do |job, error| + job.fail_import!(error) + end + + retry_on DataImports::Intercom::Client::RateLimitError, wait: 1.minute, attempts: 5 do |job, error| + job.fail_import!(error) + end + + def fail_import!(error) + data_import = arguments.first + run_id = arguments.length > 1 ? arguments.last : nil + return if data_import.blank? || skip_import?(data_import, run_id) + + DataImports::Intercom::Importer.new(data_import: data_import, run_id: run_id).fail!(error) + end + + private + + def skip_import?(data_import, run_id = nil) + data_import.reload + data_import.abandoned? || data_import.failed? || data_import.completed? || + data_import.completed_with_errors? || stale_import_run?(data_import, run_id) + end + + def stale_import_run?(data_import, run_id) + active_run_id = data_import.active_intercom_import_run_id + active_run_id.present? && active_run_id != run_id + end + + def importer_for(data_import, run_id = nil) + DataImports::Intercom::Importer.new(data_import: data_import, run_id: run_id) + end + + def fail_unexpected_error(importer, error) + raise error if error.is_a?(DataImports::Intercom::Client::Error) + + importer&.fail!(error) + raise error + end +end diff --git a/app/jobs/data_imports/intercom/contacts_page_job.rb b/app/jobs/data_imports/intercom/contacts_page_job.rb new file mode 100644 index 000000000..89f51a5b4 --- /dev/null +++ b/app/jobs/data_imports/intercom/contacts_page_job.rb @@ -0,0 +1,26 @@ +class DataImports::Intercom::ContactsPageJob < DataImports::Intercom::BaseJob + def perform(data_import, starting_after = nil, run_id = nil) + return if skip_import?(data_import, run_id) + + importer = importer_for(data_import, run_id) + return enqueue_conversations_or_finish(data_import, importer, run_id) if importer.contacts_completed? + + result = importer.import_contacts_page(starting_after: starting_after) + return if skip_import?(data_import, run_id) + return self.class.perform_later(data_import, result.next_cursor, run_id) unless result.done? + + enqueue_conversations_or_finish(data_import, importer, run_id) + rescue StandardError => e + fail_unexpected_error(importer, e) + end + + private + + def enqueue_conversations_or_finish(data_import, importer, run_id) + if importer.import_conversations? && !importer.conversations_completed? + DataImports::Intercom::ConversationsPageJob.perform_later(data_import, importer.cursor_for('conversations'), run_id) + else + importer.finish! + end + end +end diff --git a/app/jobs/data_imports/intercom/conversations_page_job.rb b/app/jobs/data_imports/intercom/conversations_page_job.rb new file mode 100644 index 000000000..8b8d92e6d --- /dev/null +++ b/app/jobs/data_imports/intercom/conversations_page_job.rb @@ -0,0 +1,16 @@ +class DataImports::Intercom::ConversationsPageJob < DataImports::Intercom::BaseJob + def perform(data_import, starting_after = nil, run_id = nil) + return if skip_import?(data_import, run_id) + + importer = importer_for(data_import, run_id) + return importer.finish! if importer.conversations_completed? + + result = importer.import_conversations_page(starting_after: starting_after) + return if skip_import?(data_import, run_id) + return self.class.perform_later(data_import, result.next_cursor, run_id) unless result.done? + + importer.finish! + rescue StandardError => e + fail_unexpected_error(importer, e) + end +end diff --git a/app/jobs/data_imports/intercom/import_job.rb b/app/jobs/data_imports/intercom/import_job.rb new file mode 100644 index 000000000..1b054669c --- /dev/null +++ b/app/jobs/data_imports/intercom/import_job.rb @@ -0,0 +1,24 @@ +class DataImports::Intercom::ImportJob < DataImports::Intercom::BaseJob + def perform(data_import, run_id = nil) + return if skip_import?(data_import, run_id) + + importer = importer_for(data_import, run_id) + return unless importer.start! + + enqueue_next_stage(data_import, importer, run_id) + rescue StandardError => e + fail_unexpected_error(importer, e) + end + + private + + def enqueue_next_stage(data_import, importer, run_id) + if importer.import_contacts? && !importer.contacts_completed? + DataImports::Intercom::ContactsPageJob.perform_later(data_import, importer.cursor_for('contacts'), run_id) + elsif importer.import_conversations? && !importer.conversations_completed? + DataImports::Intercom::ConversationsPageJob.perform_later(data_import, importer.cursor_for('conversations'), run_id) + else + importer.finish! + end + end +end diff --git a/app/listeners/webhook_listener.rb b/app/listeners/webhook_listener.rb index c64b36ca0..bb44649dd 100644 --- a/app/listeners/webhook_listener.rb +++ b/app/listeners/webhook_listener.rb @@ -108,6 +108,8 @@ class WebhookListener < BaseListener end def deliver_account_webhooks(payload, account) + return unless account.api_and_webhooks_enabled? + account.webhooks.account_type.each do |webhook| next unless webhook.subscriptions.include?(payload[:event]) diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb index 20e194fed..22b176421 100644 --- a/app/mailers/conversation_reply_mailer.rb +++ b/app/mailers/conversation_reply_mailer.rb @@ -69,6 +69,8 @@ class ConversationReplyMailer < ApplicationMailer @agent = @conversation.assignee @inbox = @conversation.inbox @channel = @inbox.channel + Current.account = @account + Current.inbox = @inbox end def should_use_conversation_email_address? @@ -200,8 +202,24 @@ class ConversationReplyMailer < ApplicationMailer end def choose_layout + return 'mailer/base' if branded_email_layout_action? return false if action_name == 'reply_without_summary' || action_name == 'email_reply' 'mailer/base' end + + def branded_email_layout_action? + return false unless action_name.in?(%w[email_reply reply_without_summary]) + return @inbox.branded_email_layout_available? if @inbox&.email? + + @account&.feature_enabled?(:branded_email_templates) && EmailTemplate.account_branded_layout_template_for(@account).present? + end + + def liquid_droppables + super.merge({ + agent: current_message&.sender || @agent, + contact: @contact, + message: @message || @messages&.last + }) + end end diff --git a/app/models/account.rb b/app/models/account.rb index 667058a2f..00201739a 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -7,6 +7,7 @@ # custom_attributes :jsonb # domain :string(100) # feature_flags :bigint default(0), not null +# feature_flags_ext_1 :bigint default(0), not null # internal_attributes :jsonb not null # limits :jsonb # locale :integer default("en") @@ -23,7 +24,7 @@ # class Account < ApplicationRecord - # used for single column multi flags + # used for multi-flag bitset columns include FlagShihTzu include Reportable include Featurable @@ -54,7 +55,7 @@ class Account < ApplicationRecord store_accessor :settings, :captain_models, :captain_features store_accessor :settings, :reporting_timezone store_accessor :settings, :keep_pending_on_bot_failure - store_accessor :settings, :captain_auto_resolve_mode + store_accessor :settings, :captain_auto_resolve_mode, :captain_false_promise_harness_enabled include AccountCaptainAutoResolve has_many :account_users, dependent: :destroy_async @@ -153,6 +154,10 @@ class Account < ApplicationRecord } end + def api_and_webhooks_enabled? + true + end + def locale_english_name # the locale can also be something like pt_BR, en_US, fr_FR, etc. # the format is `_ ` diff --git a/app/models/account_user.rb b/app/models/account_user.rb index bbcb0e010..cdacb9b0e 100644 --- a/app/models/account_user.rb +++ b/app/models/account_user.rb @@ -39,6 +39,8 @@ class AccountUser < ApplicationRecord after_create_commit :notify_creation, :create_notification_setting after_destroy :notify_deletion, :remove_user_from_account after_save :update_presence_in_redis, if: :saved_change_to_availability? + after_commit :invalidate_filtered_unread_count_visibility, on: [:create, :destroy] + after_update_commit :invalidate_filtered_unread_count_visibility_update, if: :filtered_unread_count_visibility_changed? validates :user_id, uniqueness: { scope: :account_id } @@ -79,6 +81,22 @@ class AccountUser < ApplicationRecord def update_presence_in_redis OnlineStatusTracker.set_status(account.id, user.id, availability) end + + def filtered_unread_count_visibility_changed? + previous_changes.key?('role') || previous_changes.key?('custom_role_id') + end + + def invalidate_filtered_unread_count_visibility + ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).user_visibility_changed!(user_id: user_id) + end + + def invalidate_filtered_unread_count_visibility_update + dispatch_account_cache_invalidated if invalidate_filtered_unread_count_visibility + end + + def dispatch_account_cache_invalidated + Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, account: account, cache_keys: account.cache_keys) + end end AccountUser.prepend_mod_with('AccountUser') diff --git a/app/models/article.rb b/app/models/article.rb index a04ca05fe..eb843493a 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -5,6 +5,8 @@ # id :bigint not null, primary key # content :text # description :text +# draft_content :text +# draft_title :string # locale :string default("en"), not null # meta :jsonb # position :integer @@ -137,15 +139,41 @@ class Article < ApplicationRecord end def self.update_positions(portal:, positions_hash:) - return if positions_hash.blank? + return {} if positions_hash.blank? + + moved_ids = positions_hash.keys.map(&:to_i) transaction do positions_hash.each do |article_id, new_position| portal.articles.find(article_id).update!(position: new_position) end + # Re-space touched categories to clean gaps and return the final positions + rebalance_positions(portal, moved_ids) end end + def self.rebalance_positions(portal, moved_ids) + category_ids = portal.articles.where(id: moved_ids).distinct.pluck(:category_id).compact + category_ids.each_with_object({}) do |category_id, positions| + resequence_category(portal, category_id, moved_ids, positions) + end + end + + def self.resequence_category(portal, category_id, moved_ids, positions) + ordered = portal.articles.where(category_id: category_id) + .sort_by { |article| [article.position || 0, moved_ids.include?(article.id) ? 1 : 0, article.id] } + return if ordered.length < 2 # a lone article can't collide, leave it as-is + + ordered.each_with_index do |article, index| + new_position = (index + 1) * 10 + positions[article.id] = new_position + next if article.position == new_position + + article.update_column(:position, new_position) # rubocop:disable Rails/SkipsModelValidations + end + end + private_class_method :rebalance_positions, :resequence_category + private def category_id_changed_action diff --git a/app/models/assignment_policy.rb b/app/models/assignment_policy.rb index a76893d61..12c46aa97 100644 --- a/app/models/assignment_policy.rb +++ b/app/models/assignment_policy.rb @@ -7,6 +7,7 @@ # conversation_priority :integer default("earliest_created"), not null # description :text # enabled :boolean default(TRUE), not null +# exclude_older_than_hours :integer default(168) # fair_distribution_limit :integer default(100), not null # fair_distribution_window :integer default(3600), not null # name :string(255) not null @@ -21,6 +22,8 @@ # index_assignment_policies_on_enabled (enabled) # class AssignmentPolicy < ApplicationRecord + DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168 + belongs_to :account has_many :inbox_assignment_policies, dependent: :destroy has_many :inboxes, through: :inbox_assignment_policies @@ -28,6 +31,7 @@ class AssignmentPolicy < ApplicationRecord validates :name, presence: true, uniqueness: { scope: :account_id } validates :fair_distribution_limit, numericality: { greater_than: 0 } validates :fair_distribution_window, numericality: { greater_than: 0 } + validates :exclude_older_than_hours, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true enum conversation_priority: { earliest_created: 0, longest_waiting: 1 } diff --git a/app/models/campaign.rb b/app/models/campaign.rb index 1d4da7712..479b5bdd0 100644 --- a/app/models/campaign.rb +++ b/app/models/campaign.rb @@ -53,6 +53,7 @@ class Campaign < ApplicationRecord before_validation :ensure_correct_campaign_attributes after_commit :set_display_id, unless: :display_id? + after_destroy_commit :invalidate_filtered_unread_count_filters def trigger! return unless one_off? @@ -88,6 +89,15 @@ class Campaign < ApplicationRecord end end + def invalidate_filtered_unread_count_filters + filters_changed = ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).conversation_changed! + dispatch_account_cache_invalidated if filters_changed + end + + def dispatch_account_cache_invalidated + Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, account: account, cache_keys: account.cache_keys) + end + def set_display_id reload end diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 2a558cd13..87ff199d5 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -33,6 +33,7 @@ class Channel::Whatsapp < ApplicationRecord validate :validate_provider_config after_create :sync_templates + after_update_commit :log_credentials_transfer, if: :saved_change_to_provider_config? before_destroy :teardown_webhooks after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks? @@ -100,6 +101,13 @@ class Channel::Whatsapp < ApplicationRecord end end + # Whether the pending (unsaved) provider_config change drops the embedded_signup + # source marker, i.e. this save is an embedded signup → manual setup transfer. + def embedded_to_manual_transfer_pending? + before, after = provider_config_change + before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup' + end + def mark_message_templates_updated # rubocop:disable Rails/SkipsModelValidations update_column(:message_templates_last_updated, Time.zone.now) @@ -129,6 +137,16 @@ class Channel::Whatsapp < ApplicationRecord errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config? end + # Logs only the embedded signup → manual migration (the save drops the + # embedded_signup source marker), so credential rotations on inboxes that are + # already manual stay silent. + def log_credentials_transfer + before, after = saved_change_to_provider_config + return unless before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup' + + Rails.logger.info("[WHATSAPP_EMBEDDED_TO_MANUAL] success account_id=#{account_id} channel_id=#{id}") + end + def perform_webhook_setup webhook_setup_service.perform end diff --git a/app/models/concerns/account_cache_revalidator.rb b/app/models/concerns/account_cache_revalidator.rb index b5ff5a473..d2bfaf294 100644 --- a/app/models/concerns/account_cache_revalidator.rb +++ b/app/models/concerns/account_cache_revalidator.rb @@ -6,6 +6,8 @@ module AccountCacheRevalidator end def update_account_cache + return if account.blank? + account.update_cache_key(self.class.name.underscore) end end diff --git a/app/models/concerns/account_settings_schema.rb b/app/models/concerns/account_settings_schema.rb index 52e1c2811..755ea009e 100644 --- a/app/models/concerns/account_settings_schema.rb +++ b/app/models/concerns/account_settings_schema.rb @@ -1,6 +1,9 @@ module AccountSettingsSchema extend ActiveSupport::Concern + CAPTAIN_MODEL_PROPERTIES = Llm::Models.feature_keys.index_with { { 'type': %w[string null] } }.freeze + CAPTAIN_FEATURE_PROPERTIES = Llm::Models.feature_keys.index_with { { 'type': %w[boolean null] } }.freeze + SETTINGS_PARAMS_SCHEMA = { 'type': 'object', 'properties': @@ -12,32 +15,19 @@ module AccountSettingsSchema 'auto_resolve_label': { 'type': %w[string null] }, 'keep_pending_on_bot_failure': { 'type': %w[boolean null] }, 'captain_auto_resolve_mode': { 'type': %w[string null], 'enum': ['evaluated', 'legacy', 'disabled', nil] }, + 'captain_false_promise_harness_enabled': { 'type': %w[boolean null] }, 'conversation_required_attributes': { 'type': %w[array null], 'items': { 'type': 'string' } }, 'captain_models': { 'type': %w[object null], - 'properties': { - 'editor': { 'type': %w[string null] }, - 'assistant': { 'type': %w[string null] }, - 'copilot': { 'type': %w[string null] }, - 'label_suggestion': { 'type': %w[string null] }, - 'audio_transcription': { 'type': %w[string null] }, - 'help_center_search': { 'type': %w[string null] } - }, + 'properties': CAPTAIN_MODEL_PROPERTIES, 'additionalProperties': false }, 'captain_features': { 'type': %w[object null], - 'properties': { - 'editor': { 'type': %w[boolean null] }, - 'assistant': { 'type': %w[boolean null] }, - 'copilot': { 'type': %w[boolean null] }, - 'label_suggestion': { 'type': %w[boolean null] }, - 'audio_transcription': { 'type': %w[boolean null] }, - 'help_center_search': { 'type': %w[boolean null] } - }, + 'properties': CAPTAIN_FEATURE_PROPERTIES, 'additionalProperties': false } }, diff --git a/app/models/concerns/activity_message_handler.rb b/app/models/concerns/activity_message_handler.rb index 0300bd2d1..b4197ac2d 100644 --- a/app/models/concerns/activity_message_handler.rb +++ b/app/models/concerns/activity_message_handler.rb @@ -54,7 +54,20 @@ module ActivityMessageHandler user_status_change_activity_content(user_name) end - ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content + return if content.blank? + + ::Conversations::ActivityMessageJob.perform_later( + self, + activity_message_params( + content, + content_attributes: { + activity: { + type: 'conversation_status_changed', + status: status + } + } + ) + ) end def auto_resolve_message_key(minutes) @@ -87,8 +100,10 @@ module ActivityMessageHandler end end - def activity_message_params(content) - { account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content } + def activity_message_params(content, content_attributes: nil) + params = { account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content } + params[:content_attributes] = content_attributes if content_attributes.present? + params end def create_muted_message diff --git a/app/models/concerns/captain_featurable.rb b/app/models/concerns/captain_featurable.rb index af73fded3..16566eb25 100644 --- a/app/models/concerns/captain_featurable.rb +++ b/app/models/concerns/captain_featurable.rb @@ -4,6 +4,7 @@ module CaptainFeaturable extend ActiveSupport::Concern included do + before_validation :normalize_captain_models validate :validate_captain_models # Dynamically define accessor methods for each captain feature @@ -30,14 +31,8 @@ module CaptainFeaturable private def captain_models_with_defaults - stored_models = captain_models || {} - Llm::Models.feature_keys.each_with_object({}) do |feature_key, result| - stored_value = stored_models[feature_key] - result[feature_key] = if stored_value.present? && Llm::Models.valid_model_for?(feature_key, stored_value) - stored_value - else - Llm::Models.default_model_for(feature_key) - end + Llm::Models.feature_keys.index_with do |feature_key| + Llm::FeatureRouter.resolve(feature: feature_key, account: self)[:model] end end @@ -52,11 +47,27 @@ module CaptainFeaturable return if captain_models.blank? captain_models.each do |feature_key, model_name| - next if model_name.blank? + unless Llm::Models.feature?(feature_key) + errors.add(:captain_models, "'#{feature_key}' is not a known feature") + next + end + next if Llm::Models.valid_model_for?(feature_key, model_name) allowed_models = Llm::Models.models_for(feature_key) errors.add(:captain_models, "'#{model_name}' is not a valid model for #{feature_key}. Allowed: #{allowed_models.join(', ')}") end end + + def normalize_captain_models + return unless captain_models.is_a?(Hash) + + normalized_models = captain_models.each_with_object({}) do |(feature_key, model_name), result| + next if model_name.blank? + + result[feature_key.to_s] = model_name.to_s + end + + self.captain_models = normalized_models.presence + end end diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb index daa0b4bf6..914df8449 100644 --- a/app/models/concerns/featurable.rb +++ b/app/models/concerns/featurable.rb @@ -1,6 +1,10 @@ module Featurable extend ActiveSupport::Concern + DEFAULT_FEATURE_FLAG_COLUMN = 'feature_flags'.freeze + FEATURE_FLAG_COLUMNS = [DEFAULT_FEATURE_FLAG_COLUMN, 'feature_flags_ext_1'].freeze + MAX_FEATURES_PER_COLUMN = 63 + QUERY_MODE = { flag_query_mode: :bit_operator, check_for_column: false @@ -8,15 +12,62 @@ module Featurable FEATURE_LIST = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze - FEATURES = FEATURE_LIST.each_with_object({}) do |feature, result| - result[result.keys.size + 1] = "feature_#{feature['name']}".to_sym + def self.feature_flag_mappings_for(feature_list) + features_by_column = feature_list.group_by { |feature| feature['column'].presence || DEFAULT_FEATURE_FLAG_COLUMN } + + mappings = FEATURE_FLAG_COLUMNS.index_with do |column| + features = features_by_column.delete(column) || [] + validate_feature_count!(column, features) + + features.each_with_index.to_h do |feature, index| + [index + 1, "feature_#{feature['name']}".to_sym] + end + end + + validate_feature_columns!(features_by_column) + mappings end + def self.validate_feature_count!(column, features) + return if features.size <= MAX_FEATURES_PER_COLUMN + + raise ArgumentError, "Account feature flag column #{column} supports up to #{MAX_FEATURES_PER_COLUMN} features" + end + + def self.validate_feature_columns!(features_by_column) + return if features_by_column.blank? + + invalid_columns = features_by_column.keys.join(', ') + raise ArgumentError, "Unknown account feature flag column: #{invalid_columns}" + end + + FEATURES_BY_COLUMN = feature_flag_mappings_for(FEATURE_LIST).freeze + included do include FlagShihTzu - has_flags FEATURES.merge(column: 'feature_flags').merge(QUERY_MODE) + + FEATURE_FLAG_COLUMNS.each do |column| + has_flags FEATURES_BY_COLUMN.fetch(column).merge(column: column).merge(QUERY_MODE) + end before_create :enable_default_features + + define_method :all_feature_flags do + FEATURE_FLAG_COLUMNS.flat_map { |column| all_flags(column) } + end + + define_method :selected_feature_flags do + FEATURE_FLAG_COLUMNS.flat_map { |column| selected_flags(column) } + end + + define_method :selected_feature_flags= do |chosen_flags| + FEATURE_FLAG_COLUMNS.each { |column| unselect_all_flags(column) } + return if chosen_flags.nil? + + chosen_flags.each do |selected_flag| + enable_flag(selected_flag.to_sym) if selected_flag.present? + end + end end def enable_features(*names) diff --git a/app/models/concerns/inbox_branded_email_layoutable.rb b/app/models/concerns/inbox_branded_email_layoutable.rb new file mode 100644 index 000000000..196ada3e2 --- /dev/null +++ b/app/models/concerns/inbox_branded_email_layoutable.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module InboxBrandedEmailLayoutable + extend ActiveSupport::Concern + + def branded_email_layout + branded_email_layout_template&.body + end + + def branded_email_layout_template + email_templates.find_by(name: EmailTemplate::BRANDED_LAYOUT_NAME, template_type: :layout, locale: EmailTemplate::DEFAULT_LOCALE) + end + + def effective_branded_email_layout_template(locale = I18n.locale) + EmailTemplate.branded_layout_for(inbox: self, account: account, locale: locale) + end + + def branded_email_layout_available? + email? && account.feature_enabled?(:branded_email_templates) && effective_branded_email_layout_template.present? + end + + def update_branded_email_layout!(body) + if body.blank? + branded_email_layout_template&.destroy! + return + end + + template = branded_email_layout_template || email_templates.new( + name: EmailTemplate::BRANDED_LAYOUT_NAME, + template_type: :layout, + locale: EmailTemplate::DEFAULT_LOCALE, + account: account + ) + template.update!(body: body) + end +end diff --git a/app/models/concerns/portal_config_schema.rb b/app/models/concerns/portal_config_schema.rb index de338b830..7e506a076 100644 --- a/app/models/concerns/portal_config_schema.rb +++ b/app/models/concerns/portal_config_schema.rb @@ -14,6 +14,18 @@ module PortalConfigSchema 'additionalProperties' => false }.freeze + # Per-locale recommended content for the portal home page: an ordered list of + # `category_ids` (the hero's "Recommended topics" pills) and `article_ids` (the + # "Recommended" articles section). When empty, the portal uses its defaults. + POPULAR_CONTENT_SCHEMA = { + 'type' => 'object', + 'properties' => { + 'category_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } }, + 'article_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } } + }, + 'additionalProperties' => false + }.freeze + CONFIG_PARAMS_SCHEMA = { 'type' => 'object', 'properties' => { @@ -27,6 +39,10 @@ module PortalConfigSchema 'locale_translations' => { 'type' => %w[object null], 'additionalProperties' => LOCALE_TRANSLATION_SCHEMA + }, + 'popular_content' => { + 'type' => %w[object null], + 'additionalProperties' => POPULAR_CONTENT_SCHEMA } }, 'required' => [], diff --git a/app/models/conversation.rb b/app/models/conversation.rb index d9c06c4d8..1e9c38c41 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -62,6 +62,14 @@ class Conversation < ApplicationRecord include PushDataHelper include ConversationMuteHelpers + CONVERSATION_UPDATED_ADDITIONAL_ATTRIBUTE_KEYS = %w[conversation_language].freeze + FILTERED_UNREAD_COUNT_ADDITIONAL_ATTRIBUTE_KEYS = %w[browser_language conversation_language mail_subject referer].freeze + FILTERED_UNREAD_COUNT_UPDATE_KEYS = %w[ + cached_label_list campaign_id custom_attributes first_reply_created_at label_list last_activity_at priority snoozed_until waiting_since + ].freeze + private_constant :CONVERSATION_UPDATED_ADDITIONAL_ATTRIBUTE_KEYS, :FILTERED_UNREAD_COUNT_ADDITIONAL_ATTRIBUTE_KEYS, + :FILTERED_UNREAD_COUNT_UPDATE_KEYS + validates :account_id, presence: true validates :inbox_id, presence: true validates :contact_id, presence: true @@ -247,6 +255,7 @@ class Conversation < ApplicationRecord handle_resolved_status_change notify_status_change create_activity + invalidate_filtered_unread_count_conversation notify_conversation_updation end @@ -313,10 +322,23 @@ class Conversation < ApplicationRecord end def allowed_keys? - ( - previous_changes.keys.intersect?(list_of_keys) || - (previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language])) - ) + previous_changes.keys.intersect?(list_of_keys) || + additional_attributes_changed?(CONVERSATION_UPDATED_ADDITIONAL_ATTRIBUTE_KEYS) + end + + def invalidate_filtered_unread_count_conversation + return unless filtered_unread_count_update? + + ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).conversation_changed! + end + + def filtered_unread_count_update? + previous_changes.keys.intersect?(FILTERED_UNREAD_COUNT_UPDATE_KEYS) || + additional_attributes_changed?(FILTERED_UNREAD_COUNT_ADDITIONAL_ATTRIBUTE_KEYS) + end + + def additional_attributes_changed?(keys) + Array(previous_changes['additional_attributes']).compact.any? { |attributes| attributes.keys.intersect?(keys) } end def load_attributes_created_by_db_triggers diff --git a/app/models/conversation_participant.rb b/app/models/conversation_participant.rb index 830eb7baa..4103deda4 100644 --- a/app/models/conversation_participant.rb +++ b/app/models/conversation_participant.rb @@ -28,6 +28,7 @@ class ConversationParticipant < ApplicationRecord belongs_to :user before_validation :ensure_account_id + after_commit :invalidate_filtered_unread_count_visibility, on: [:create, :destroy] private @@ -38,4 +39,8 @@ class ConversationParticipant < ApplicationRecord def ensure_inbox_access errors.add(:user, 'must have inbox access') if conversation && conversation.inbox.assignable_agents.exclude?(user) end + + def invalidate_filtered_unread_count_visibility + ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).user_visibility_changed!(user_id: user_id) + end end diff --git a/app/models/custom_attribute_definition.rb b/app/models/custom_attribute_definition.rb index 35f822335..8270cc70e 100644 --- a/app/models/custom_attribute_definition.rb +++ b/app/models/custom_attribute_definition.rb @@ -48,6 +48,8 @@ class CustomAttributeDefinition < ApplicationRecord belongs_to :account after_update :update_widget_pre_chat_custom_fields, unless: :company_attribute? after_destroy :sync_widget_pre_chat_custom_fields, unless: :company_attribute? + after_update_commit :invalidate_filtered_unread_count_filters_update, if: :conversation_attribute_before_or_after? + after_destroy_commit :invalidate_filtered_unread_count_filters_destroy, if: :conversation_attribute? private @@ -64,6 +66,27 @@ class CustomAttributeDefinition < ApplicationRecord ::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self) end + def invalidate_filtered_unread_count_filters_update + invalidate_filtered_unread_count_filters + end + + def invalidate_filtered_unread_count_filters_destroy + invalidate_filtered_unread_count_filters + end + + def invalidate_filtered_unread_count_filters + filters_changed = ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).custom_attribute_definition_changed!(self) + dispatch_account_cache_invalidated if filters_changed + end + + def dispatch_account_cache_invalidated + Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, account: account, cache_keys: account.cache_keys) + end + + def conversation_attribute_before_or_after? + conversation_attribute? || attribute_model_previously_was == 'conversation_attribute' + end + def attribute_must_not_conflict model_keys = attribute_model.to_s.delete_suffix('_attribute').to_sym standard_attributes = STANDARD_ATTRIBUTES[model_keys] diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb index 6d64c0447..6e2461a74 100644 --- a/app/models/custom_filter.rb +++ b/app/models/custom_filter.rb @@ -22,10 +22,31 @@ class CustomFilter < ApplicationRecord enum filter_type: { conversation: 0, contact: 1, report: 2 } validate :validate_number_of_filters + after_create_commit :invalidate_filtered_unread_count_create + after_update_commit :invalidate_filtered_unread_count_update + after_destroy_commit :invalidate_filtered_unread_count_destroy def validate_number_of_filters return true if account.custom_filters.where(user_id: user_id).size < Limits::MAX_CUSTOM_FILTERS_PER_USER errors.add :account_id, I18n.t('errors.custom_filters.number_of_records') end + + private + + def invalidate_filtered_unread_count_create + filtered_count_invalidator.custom_filter_created!(self) + end + + def invalidate_filtered_unread_count_update + filtered_count_invalidator.custom_filter_updated!(self) + end + + def invalidate_filtered_unread_count_destroy + filtered_count_invalidator.custom_filter_destroyed!(self) + end + + def filtered_count_invalidator + ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account) + end end diff --git a/app/models/data_import.rb b/app/models/data_import.rb index a44650a22..5f0ad4737 100644 --- a/app/models/data_import.rb +++ b/app/models/data_import.rb @@ -3,33 +3,115 @@ # Table name: data_imports # # id :bigint not null, primary key +# abandoned_at :datetime +# access_token :text +# completed_at :datetime +# cursor :jsonb not null # data_type :string not null +# import_types :jsonb not null +# last_error_at :datetime +# name :string # processed_records :integer # processing_errors :text +# source_metadata :jsonb not null +# source_provider :string +# source_type :string +# started_at :datetime +# stats :jsonb not null # status :integer default("pending"), not null # total_records :integer # created_at :datetime not null # updated_at :datetime not null # account_id :bigint not null +# initiated_by_id :integer # # Indexes # -# index_data_imports_on_account_id (account_id) +# index_data_imports_on_account_id (account_id) +# index_data_imports_on_initiated_by_id (initiated_by_id) +# index_data_imports_on_source_provider (source_provider) # class DataImport < ApplicationRecord + ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY = 'active_intercom_import_run_id'.freeze + LEGACY_DATA_TYPES = ['contacts'].freeze + INTEGRATION_DATA_TYPES = ['intercom'].freeze + IMPORT_TYPES = %w[contacts conversations].freeze + belongs_to :account - validates :data_type, inclusion: { in: ['contacts'], message: I18n.t('errors.data_import.data_type.invalid') } - enum status: { pending: 0, processing: 1, completed: 2, failed: 3 } + belongs_to :initiated_by, class_name: 'User', optional: true + + encrypts :access_token if Chatwoot.encryption_configured? + + has_many :items, class_name: 'DataImportItem', dependent: :destroy_async + has_many :mappings, class_name: 'DataImportMapping', dependent: :destroy_async + has_many :import_errors, class_name: 'DataImportError', dependent: :destroy_async + + validates :data_type, inclusion: { in: LEGACY_DATA_TYPES + INTEGRATION_DATA_TYPES, message: I18n.t('errors.data_import.data_type.invalid') } + validates :access_token, presence: true, on: :create, if: :intercom_import? + validate :validate_import_types + + enum status: { pending: 0, processing: 1, completed: 2, failed: 3, completed_with_errors: 6, abandoned: 7 } + + scope :active_intercom, -> { where(data_type: 'intercom', source_provider: 'intercom', status: [:pending, :processing]) } has_one_attached :import_file has_one_attached :failed_records after_create_commit :process_data_import + def legacy_contacts_csv_import? + data_type == 'contacts' && source_provider.blank? + end + + def intercom_import? + data_type == 'intercom' && source_provider == 'intercom' + end + + def restartable? + failed? || abandoned? + end + + def abandonable? + intercom_import? && (pending? || processing?) + end + + def abandon! + self.class.transaction do + abandonable_import = self.class.lock.find_by( + id: id, + data_type: 'intercom', + source_provider: 'intercom', + status: [:pending, :processing] + ) + abandonable_import&.update!(status: :abandoned, abandoned_at: Time.current) + end + reload + end + + def active_intercom_import_run_id + source_metadata.to_h[ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY] + end + + def assign_active_intercom_import_run_id + self.source_metadata = source_metadata.to_h.merge(ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => SecureRandom.uuid) + active_intercom_import_run_id + end + private def process_data_import + return unless legacy_contacts_csv_import? + # we wait for the file to be uploaded to the cloud DataImportJob.set(wait: 1.minute).perform_later(self) end + + def validate_import_types + return if import_types.blank? + + invalid_types = import_types - IMPORT_TYPES + return if invalid_types.blank? + + errors.add(:import_types, "contains unsupported values: #{invalid_types.join(', ')}") + end end diff --git a/app/models/data_import_error.rb b/app/models/data_import_error.rb new file mode 100644 index 000000000..663050caa --- /dev/null +++ b/app/models/data_import_error.rb @@ -0,0 +1,33 @@ +# == Schema Information +# +# Table name: data_import_errors +# +# id :bigint not null, primary key +# details :jsonb not null +# error_code :string not null +# message :text +# source_object_type :string +# created_at :datetime not null +# updated_at :datetime not null +# data_import_id :bigint not null +# data_import_item_id :bigint +# source_object_id :string +# +# Indexes +# +# idx_data_import_errors_on_source (source_object_type,source_object_id) +# index_data_import_errors_on_data_import_id (data_import_id) +# index_data_import_errors_on_data_import_item_id (data_import_item_id) +# +class DataImportError < ApplicationRecord + SKIP_LOG_KINDS = %w[failed skipped].freeze + + belongs_to :data_import + belongs_to :data_import_item, optional: true + + validates :error_code, presence: true + + scope :skip_logs, -> { where("details ->> 'kind' IN (:kinds)", kinds: SKIP_LOG_KINDS) } + scope :failed, -> { where("details ->> 'kind' = ?", 'failed') } + scope :non_skip_logs, -> { where("details ->> 'kind' IS NULL OR details ->> 'kind' NOT IN (:kinds)", kinds: SKIP_LOG_KINDS) } +end diff --git a/app/models/data_import_item.rb b/app/models/data_import_item.rb new file mode 100644 index 000000000..1ac9c2812 --- /dev/null +++ b/app/models/data_import_item.rb @@ -0,0 +1,35 @@ +# == Schema Information +# +# Table name: data_import_items +# +# id :bigint not null, primary key +# attempt_count :integer default(0), not null +# chatwoot_record_type :string +# last_error_code :string +# last_error_message :text +# metadata :jsonb not null +# source_object_type :string not null +# source_provider :string not null +# status :integer default("pending"), not null +# created_at :datetime not null +# updated_at :datetime not null +# chatwoot_record_id :bigint +# data_import_id :bigint not null +# source_object_id :string not null +# +# Indexes +# +# idx_data_import_items_on_import_and_source (data_import_id,source_object_type,source_object_id) UNIQUE +# idx_data_import_items_on_record (chatwoot_record_type,chatwoot_record_id) +# idx_data_import_items_on_source (source_provider,source_object_type,source_object_id) +# index_data_import_items_on_data_import_id (data_import_id) +# +class DataImportItem < ApplicationRecord + belongs_to :data_import + has_many :import_errors, class_name: 'DataImportError', dependent: :destroy_async + + validates :source_provider, :source_object_type, :source_object_id, presence: true + validates :source_object_id, uniqueness: { scope: [:data_import_id, :source_object_type] } + + enum status: { pending: 0, processing: 1, imported: 2, skipped: 3, failed: 4 } +end diff --git a/app/models/data_import_mapping.rb b/app/models/data_import_mapping.rb new file mode 100644 index 000000000..29da663f0 --- /dev/null +++ b/app/models/data_import_mapping.rb @@ -0,0 +1,33 @@ +# == Schema Information +# +# Table name: data_import_mappings +# +# id :bigint not null, primary key +# chatwoot_record_type :string not null +# metadata :jsonb not null +# source_object_type :string not null +# source_provider :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null +# chatwoot_record_id :bigint not null +# data_import_id :bigint not null +# source_object_id :string not null +# +# Indexes +# +# idx_data_import_mappings_on_account_and_source (account_id,source_provider,source_object_type,source_object_id) UNIQUE +# idx_data_import_mappings_on_record (chatwoot_record_type,chatwoot_record_id) +# index_data_import_mappings_on_data_import_id (data_import_id) +# +class DataImportMapping < ApplicationRecord + belongs_to :data_import + belongs_to :account + + validates :source_provider, :source_object_type, :source_object_id, :chatwoot_record_type, :chatwoot_record_id, presence: true + validates :source_object_id, uniqueness: { scope: [:account_id, :source_provider, :source_object_type] } + + def chatwoot_record + chatwoot_record_type.constantize.find_by(id: chatwoot_record_id) + end +end diff --git a/app/models/email_template.rb b/app/models/email_template.rb index 57db7a94c..dd7f20f6e 100644 --- a/app/models/email_template.rb +++ b/app/models/email_template.rb @@ -10,19 +10,111 @@ # created_at :datetime not null # updated_at :datetime not null # account_id :integer +# inbox_id :integer # # Indexes # -# index_email_templates_on_name_and_account_id (name,account_id) UNIQUE +# index_email_templates_on_account_scope (account_id,name,template_type,locale) UNIQUE WHERE ((account_id IS NOT NULL) AND (inbox_id IS NULL)) +# index_email_templates_on_inbox_id (inbox_id) +# index_email_templates_on_inbox_scope (inbox_id,name,template_type,locale) UNIQUE WHERE (inbox_id IS NOT NULL) +# index_email_templates_on_installation_scope (name,template_type,locale) UNIQUE WHERE ((account_id IS NULL) AND (inbox_id IS NULL)) # class EmailTemplate < ApplicationRecord + BRANDED_LAYOUT_NAME = 'base'.freeze + DEFAULT_LOCALE = 'en'.freeze + CONTENT_FOR_LAYOUT_PATTERN = /\{\{\s*content_for_layout\s*\}\}/ + enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true enum :template_type, { layout: 0, content: 1 } belongs_to :account, optional: true + belongs_to :inbox, optional: true - validates :name, uniqueness: { scope: :account } + validates :name, + uniqueness: { scope: %i[template_type locale], conditions: -> { where(account_id: nil, inbox_id: nil) } }, + if: :installation_scoped? + validates :name, uniqueness: { scope: %i[account_id template_type locale], conditions: -> { where(inbox_id: nil) } }, if: :account_scoped? + validates :name, uniqueness: { scope: %i[inbox_id template_type locale] }, if: :inbox_scoped? + validate :validate_inbox_account + validate :validate_liquid_body + validate :validate_layout_slot, if: :layout? def self.resolver(options = {}) ::EmailTemplates::DbResolverService.using self, options end + + def self.branded_layout_for(inbox:, account:, locale: I18n.locale) + layout_template_for_scope(inbox: inbox, account: account, locale: locale) + end + + def self.account_branded_layout_template_for(account) + find_by(account: account, inbox: nil, name: BRANDED_LAYOUT_NAME, template_type: :layout, locale: DEFAULT_LOCALE) + end + + def self.update_account_branded_layout!(account:, body:) + if body.blank? + account_branded_layout_template_for(account)&.destroy! + return + end + + template = account_branded_layout_template_for(account) || new( + account: account, + name: BRANDED_LAYOUT_NAME, + template_type: :layout, + locale: DEFAULT_LOCALE + ) + template.update!(body: body) + end + + def self.locale_candidates(locale) + candidate = locale.to_s + ([candidate] + [DEFAULT_LOCALE]).select { |locale_key| locales.key?(locale_key) }.uniq + end + + def self.layout_template_for_scope(inbox:, account:, locale:) + scoped_relations = [] + scoped_relations << where(inbox: inbox) if inbox.present? + scoped_relations << where(account: account, inbox: nil) if account.present? + scoped_relations << where(account: nil, inbox: nil) + + scoped_relations.each do |relation| + locale_candidates(locale).each do |locale_key| + template = relation.find_by(name: BRANDED_LAYOUT_NAME, template_type: :layout, locale: locale_key) + return template if template.present? + end + end + nil + end + + private + + def installation_scoped? + account_id.nil? && inbox_id.nil? + end + + def account_scoped? + account_id.present? && inbox_id.nil? + end + + def inbox_scoped? + inbox_id.present? + end + + def validate_inbox_account + return if inbox.blank? || account.blank? + return if inbox.account_id == account_id + + errors.add(:account, 'must match inbox account') + end + + def validate_liquid_body + Liquid::Template.parse(body.to_s) + rescue Liquid::Error => e + errors.add(:body, "has invalid Liquid syntax: #{e.message}") + end + + def validate_layout_slot + return if body.to_s.match?(CONTENT_FOR_LAYOUT_PATTERN) + + errors.add(:body, 'must include {{ content_for_layout }}') + end end diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 15bfe77dd..69f41180a 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -45,6 +45,7 @@ class Inbox < ApplicationRecord include OutOfOffisable include AccountCacheRevalidator include InboxAgentAvailability + include InboxBrandedEmailLayoutable # Not allowing characters: validates :name, presence: true @@ -67,6 +68,7 @@ class Inbox < ApplicationRecord has_many :members, through: :inbox_members, source: :user has_many :conversations, dependent: :destroy_async has_many :messages, dependent: :destroy_async + has_many :email_templates, dependent: :destroy_async has_one :inbox_assignment_policy, dependent: :destroy has_one :assignment_policy, through: :inbox_assignment_policy @@ -77,10 +79,12 @@ class Inbox < ApplicationRecord enum sender_name_type: { friendly: 0, professional: 1 } + before_destroy :capture_filtered_unread_count_user_ids, prepend: true after_destroy :delete_round_robin_agents after_create_commit :dispatch_create_event after_update_commit :dispatch_update_event + after_destroy_commit :invalidate_filtered_unread_counts_after_destroy scope :order_by_name, -> { order('lower(name) ASC') } @@ -261,6 +265,18 @@ class Inbox < ApplicationRecord ::AutoAssignment::InboxRoundRobinService.new(inbox: self).clear_queue end + def capture_filtered_unread_count_user_ids + return if account.blank? + + @filtered_unread_count_user_ids = (inbox_members.pluck(:user_id) + account.account_users.administrator.pluck(:user_id)).uniq + end + + def invalidate_filtered_unread_counts_after_destroy + invalidator = ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account) + invalidator.conversation_changed! + invalidator.users_visibility_changed!(user_ids: @filtered_unread_count_user_ids) + end + def check_channel_type? ['Channel::Email', 'Channel::Api', 'Channel::WebWidget'].include?(channel_type) end diff --git a/app/models/inbox_member.rb b/app/models/inbox_member.rb index d4a36ccc8..bc4014da9 100644 --- a/app/models/inbox_member.rb +++ b/app/models/inbox_member.rb @@ -24,6 +24,7 @@ class InboxMember < ApplicationRecord after_create :add_agent_to_round_robin after_destroy :remove_agent_from_round_robin + after_commit :invalidate_filtered_unread_count_visibility, on: [:create, :destroy] private @@ -34,6 +35,10 @@ class InboxMember < ApplicationRecord def remove_agent_from_round_robin ::AutoAssignment::InboxRoundRobinService.new(inbox: inbox).remove_agent_from_queue(user_id) if inbox.present? end + + def invalidate_filtered_unread_count_visibility + ::Conversations::UnreadCounts::FilteredCountInvalidator.new(inbox&.account).user_visibility_changed!(user_id: user_id) + end end InboxMember.include_mod_with('Audit::InboxMember') diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index a3396951f..36515eb66 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -30,6 +30,7 @@ class Integrations::Hook < ApplicationRecord validate :validate_settings_json_schema validate :ensure_feature_enabled validate :validate_openai_api_key, if: :validate_openai_api_key? + validate :validate_cloudflare_realtimekit_credentials, if: :validate_cloudflare_realtimekit_credentials? validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } } # TODO: This seems to be only used for slack at the moment @@ -61,6 +62,10 @@ class Integrations::Hook < ApplicationRecord app_id == 'openai' end + def dyte? + app_id == 'dyte' + end + def notion? app_id == 'notion' end @@ -96,6 +101,7 @@ class Integrations::Hook < ApplicationRecord def validate_settings_json_schema return if app.blank? || app.params[:settings_json_schema].blank? + return if legacy_dyte_settings_unchanged? errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings) end @@ -106,18 +112,57 @@ class Integrations::Hook < ApplicationRecord openai? && enabled? && (new_record? || openai_api_key_changed? || will_save_change_to_status?) end + def validate_cloudflare_realtimekit_credentials? + dyte? && enabled? && !legacy_dyte_settings_unchanged? && + (new_record? || cloudflare_realtimekit_credentials_changed? || will_save_change_to_status?) + end + def openai_api_key_changed? settings_api_key(settings) != settings_api_key(settings_in_database) end + def cloudflare_realtimekit_credentials_changed? + settings_cloudflare_realtimekit_credentials(settings) != settings_cloudflare_realtimekit_credentials(settings_in_database) + end + + def legacy_dyte_settings_unchanged? + dyte? && persisted? && !will_save_change_to_settings? && legacy_dyte_settings?(settings_in_database) + end + + def legacy_dyte_settings?(value) + return false if value.blank? + + %w[organization_id api_key].any? { |key| settings_value(value, key).present? } && + %w[account_id app_id api_token].none? { |key| settings_value(value, key).present? } + end + def validate_openai_api_key return if Integrations::Openai::KeyValidator.valid?(settings_api_key(settings)) errors.add(:base, I18n.t('errors.openai.invalid_api_key')) end + def validate_cloudflare_realtimekit_credentials + result = Integrations::Cloudflare::RealtimeKitCredentialsValidator.validate(*settings_cloudflare_realtimekit_credentials(settings)) + return if result.success? + + errors.add(:base, I18n.t("errors.cloudflare.realtimekit.#{result.error}")) + end + def settings_api_key(value) - value&.dig('api_key') || value&.dig(:api_key) + settings_value(value, 'api_key') + end + + def settings_cloudflare_realtimekit_credentials(value) + [ + settings_value(value, 'account_id'), + settings_value(value, 'app_id'), + settings_value(value, 'api_token') + ] + end + + def settings_value(value, key) + value&.dig(key) || value&.dig(key.to_sym) end def trigger_setup_if_crm diff --git a/app/models/message.rb b/app/models/message.rb index f25d2e112..220bdd549 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -34,7 +34,7 @@ # index_messages_on_conversation_id (conversation_id) # index_messages_on_created_at (created_at) # index_messages_on_inbox_id (inbox_id) -# index_messages_on_sender_type_and_sender_id (sender_type,sender_id) +# index_messages_on_sender_and_created (sender_type,sender_id,created_at) # index_messages_on_source_id (source_id) # diff --git a/app/models/portal.rb b/app/models/portal.rb index 9d2da6965..b1f387b49 100644 --- a/app/models/portal.rb +++ b/app/models/portal.rb @@ -53,7 +53,12 @@ class Portal < ApplicationRecord scope :active, -> { where(archived: false) } # TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config - CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations].freeze + CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations + popular_content].freeze + + # Max number of recommended categories/articles shown per locale. + POPULAR_CATEGORY_LIMIT = 3 + POPULAR_ARTICLE_LIMIT = 6 def file_base_data { @@ -115,6 +120,14 @@ class Portal < ApplicationRecord config_value('layout').presence || 'classic' end + def popular_category_ids(locale = default_locale) + Array(config.dig('popular_content', locale.to_s, 'category_ids')).first(POPULAR_CATEGORY_LIMIT) + end + + def popular_article_ids(locale = default_locale) + Array(config.dig('popular_content', locale.to_s, 'article_ids')).first(POPULAR_ARTICLE_LIMIT) + end + def social_profiles config_value('social_profiles') || {} end diff --git a/app/models/team.rb b/app/models/team.rb index a2b69c7fb..b19f215ca 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -5,6 +5,8 @@ # id :bigint not null, primary key # allow_auto_assign :boolean default(TRUE) # description :text +# icon :string default("") +# icon_color :string default("") # name :string not null # created_at :datetime not null # updated_at :datetime not null @@ -23,12 +25,15 @@ class Team < ApplicationRecord has_many :members, through: :team_members, source: :user has_many :conversations, dependent: :nullify + before_destroy :capture_filtered_unread_count_member_ids, prepend: true + after_destroy_commit :invalidate_filtered_unread_counts_after_destroy + validates :name, presence: { message: I18n.t('errors.validations.presence') }, uniqueness: { scope: :account_id } before_validation do - self.name = name.downcase if attribute_present?('name') + self.name = name.gsub(/[[:cntrl:]]/, '').strip.downcase if attribute_present?('name') end # Adds multiple members to the team @@ -62,9 +67,23 @@ class Team < ApplicationRecord def push_event_data { id: id, - name: name + name: name, + icon: icon, + icon_color: icon_color } end + + private + + def capture_filtered_unread_count_member_ids + @filtered_unread_count_member_ids = team_members.pluck(:user_id) + end + + def invalidate_filtered_unread_counts_after_destroy + invalidator = ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account) + invalidator.conversation_changed! + invalidator.users_visibility_changed!(user_ids: @filtered_unread_count_member_ids) + end end Team.include_mod_with('Audit::Team') diff --git a/app/models/team_member.rb b/app/models/team_member.rb index f99af264b..0d0e0aa22 100644 --- a/app/models/team_member.rb +++ b/app/models/team_member.rb @@ -18,6 +18,14 @@ class TeamMember < ApplicationRecord belongs_to :user belongs_to :team validates :user_id, uniqueness: { scope: :team_id } + + after_commit :invalidate_filtered_unread_count_visibility, on: [:create, :destroy] + + private + + def invalidate_filtered_unread_count_visibility + ::Conversations::UnreadCounts::FilteredCountInvalidator.new(team&.account).user_visibility_changed!(user_id: user_id) + end end TeamMember.include_mod_with('Audit::TeamMember') diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index bd7b3cefe..18b8216f7 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -23,6 +23,10 @@ class AccountPolicy < ApplicationPolicy @account_user.administrator? end + def select_billing_currency? + @account_user.administrator? + end + def checkout? @account_user.administrator? end @@ -34,4 +38,8 @@ class AccountPolicy < ApplicationPolicy def topup_checkout? @account_user.administrator? end + + def topup_options? + @account_user.administrator? + end end diff --git a/app/policies/dashboard_app_policy.rb b/app/policies/dashboard_app_policy.rb new file mode 100644 index 000000000..af7bec82a --- /dev/null +++ b/app/policies/dashboard_app_policy.rb @@ -0,0 +1,21 @@ +class DashboardAppPolicy < ApplicationPolicy + def index? + true + end + + def show? + true + end + + def create? + @account_user.administrator? + end + + def update? + @account_user.administrator? + end + + def destroy? + @account_user.administrator? + end +end diff --git a/app/policies/data_import_policy.rb b/app/policies/data_import_policy.rb new file mode 100644 index 000000000..3f908ca5d --- /dev/null +++ b/app/policies/data_import_policy.rb @@ -0,0 +1,41 @@ +class DataImportPolicy < ApplicationPolicy + def index? + @account_user.administrator? + end + + def show? + @account_user.administrator? && record.account_id == account.id + end + + def create? + @account_user.administrator? + end + + def validate_source? + create? + end + + def start? + show? + end + + def abandon? + show? + end + + def skip_logs? + show? + end + + def error_logs? + show? + end + + class Scope < Scope + def resolve + return scope.where(account_id: account.id) if account_user.administrator? + + scope.none + end + end +end diff --git a/app/services/auto_assignment/assignment_service.rb b/app/services/auto_assignment/assignment_service.rb index 0ad8a9004..b29f0ca02 100644 --- a/app/services/auto_assignment/assignment_service.rb +++ b/app/services/auto_assignment/assignment_service.rb @@ -35,8 +35,11 @@ class AutoAssignment::AssignmentService def unassigned_conversations(limit) scope = inbox.conversations.unassigned.open - # Apply conversation priority using assignment policy if available + # Skip stale backlog with no activity beyond the age threshold policy = inbox.assignment_policy + scope = apply_age_exclusions(scope, age_exclusion_hours(policy)) + + # Apply conversation priority using assignment policy if available scope = if policy&.longest_waiting? scope.reorder(last_activity_at: :asc, created_at: :asc) else @@ -46,6 +49,22 @@ class AutoAssignment::AssignmentService scope.limit(limit) end + def age_exclusion_hours(policy) + return policy.exclude_older_than_hours if policy + + AssignmentPolicy::DEFAULT_EXCLUDE_OLDER_THAN_HOURS + end + + def apply_age_exclusions(scope, hours_threshold) + return scope if hours_threshold.blank? + + hours = hours_threshold.to_i + return scope unless hours.positive? + + # Use last_activity_at so reopened/active conversations aren't excluded by their original created_at + scope.where('conversations.last_activity_at >= ?', hours.hours.ago) + end + def find_available_agent(conversation = nil) agents = filter_agents_by_team(inbox.available_agents, conversation) return nil if agents.nil? diff --git a/app/services/conversations/unread_counts.rb b/app/services/conversations/unread_counts.rb index 668395063..e00f8357d 100644 --- a/app/services/conversations/unread_counts.rb +++ b/app/services/conversations/unread_counts.rb @@ -1,4 +1,10 @@ module Conversations::UnreadCounts READY_TTL = 24.hours.to_i SET_TTL = 25.hours.to_i + FILTERED_COUNT_FRESH_TTL = 5.minutes.to_i + FILTERED_COUNT_STALE_WINDOW = 1.hour.to_i + FILTERED_COUNT_REDIS_TTL = FILTERED_COUNT_FRESH_TTL + FILTERED_COUNT_STALE_WINDOW + FILTERED_COUNT_VERSION_TTL = SET_TTL + FILTERED_COUNT_MIN_REFRESH_INTERVAL = 5.minutes.to_i + MAX_INLINE_FILTER_BUILDS = 3 end diff --git a/app/services/conversations/unread_counts/counter.rb b/app/services/conversations/unread_counts/counter.rb index b1ba5ddb0..2a015f3a6 100644 --- a/app/services/conversations/unread_counts/counter.rb +++ b/app/services/conversations/unread_counts/counter.rb @@ -14,7 +14,7 @@ class Conversations::UnreadCounts::Counter end def perform - return empty_counts if permission_mode == :none + return with_filtered_counts(empty_counts, build: false) if permission_mode == :none ensure_base_cache! ensure_assignment_cache! if assignment_mode? @@ -26,11 +26,17 @@ class Conversations::UnreadCounts::Counter inboxes: inbox_counts, labels: unread_label_counts, teams: unread_team_counts - } + }.then { |counts| with_filtered_counts(counts) } end private + def with_filtered_counts(counts, build: true) + return counts unless account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG) + + counts.merge(build ? filtered_counter.perform : ::Conversations::UnreadCounts::FilteredCounter.empty_counts) + end + def ensure_base_cache! ensure_cache_ready!( ready: -> { store.base_ready?(account.id) }, @@ -200,4 +206,8 @@ class Conversations::UnreadCounts::Counter def store ::Conversations::UnreadCounts::Store end + + def filtered_counter + @filtered_counter ||= ::Conversations::UnreadCounts::FilteredCounter.new(account: account, user: user) + end end diff --git a/app/services/conversations/unread_counts/filter_query_counter.rb b/app/services/conversations/unread_counts/filter_query_counter.rb new file mode 100644 index 000000000..238bed2a1 --- /dev/null +++ b/app/services/conversations/unread_counts/filter_query_counter.rb @@ -0,0 +1,151 @@ +class Conversations::UnreadCounts::FilterQueryCounter < Conversations::FilterService + BOOLEAN_VALUES = %w[0 1 false f n no off on t true y yes].freeze + DATABASE_CAST_ERROR_CLASS_NAMES = %w[ + PG::DatetimeFieldOverflow + PG::InvalidDatetimeFormat + PG::InvalidTextRepresentation + PG::NumericValueOutOfRange + ].freeze + DAYS_BEFORE_FILTER_OPERATOR = 'days_before'.freeze + MALFORMED_QUERY_ERRORS = [NoMethodError, TypeError].freeze + NUMERIC_ATTRIBUTE_KEYS = %w[assignee_id inbox_id].freeze + TEXT_DATA_TYPES = %w[labels link text text_case_insensitive].freeze + TEXT_FILTER_OPERATORS = %w[contains does_not_contain].freeze + TYPED_DATA_TYPES = %w[boolean date number numeric].freeze + VALID_QUERY_OPERATORS = %w[AND OR].freeze + VALIDATION_DATA_TYPES = (TEXT_DATA_TYPES + TYPED_DATA_TYPES).freeze + VALUELESS_FILTER_OPERATORS = %w[is_present is_not_present].freeze + + def initialize(account:, user:, query:) + super(query.with_indifferent_access, user, account) + end + + def perform + return unless valid_query? + return unless valid_typed_values? + + validate_query_operator + query_builder(@filters['conversations']).count + rescue *MALFORMED_QUERY_ERRORS + nil + rescue ActiveRecord::StatementInvalid => e + raise unless database_cast_error?(e) + + nil + end + + def base_relation + Conversations::PermissionFilterService.new(unread_conversations, @user, @account).perform + end + + private + + def valid_query? + @params[:payload].is_a?(Array) && valid_query_operator_positions? + end + + def database_cast_error?(error) + DATABASE_CAST_ERROR_CLASS_NAMES.include?(error.cause&.class&.name) + end + + def valid_query_operator_positions? + @params[:payload].each_with_index.all? do |query_hash, index| + query_operator_position_valid?(query_hash[:query_operator], last_query?(index)) + end + end + + def query_operator_position_valid?(query_operator, last_query) + return query_operator.blank? if last_query + + VALID_QUERY_OPERATORS.include?(query_operator.to_s.upcase) + end + + def last_query?(index) + index == @params[:payload].length - 1 + end + + def valid_typed_values? + @params[:payload].all? do |query_hash| + next true if VALUELESS_FILTER_OPERATORS.include?(query_hash[:filter_operator]) + + data_type = validation_data_type(query_hash) + next true if data_type.blank? + next false if text_filter_operator?(query_hash) && TYPED_DATA_TYPES.include?(data_type) + + valid_typed_values_for?(query_hash[:values], data_type, query_hash[:filter_operator]) + end + end + + def validation_data_type(query_hash) + attribute_key = query_hash[:attribute_key] + data_type = filter_data_type(query_hash) + + return nil if text_search_on_display_id?(query_hash) + return 'number' if NUMERIC_ATTRIBUTE_KEYS.include?(attribute_key) + return data_type if VALIDATION_DATA_TYPES.include?(data_type) + + nil + end + + def filter_data_type(query_hash) + attribute_key = query_hash[:attribute_key] + data_type = @filters.dig('conversations', attribute_key, 'data_type') + return data_type.to_s.downcase if data_type.present? + + custom_attribute_data_type(query_hash) + end + + def custom_attribute_data_type(query_hash) + custom_attribute_type = query_hash[:custom_attribute_type].presence || self.class::ATTRIBUTE_MODEL + custom_attribute = @account.custom_attribute_definitions.where( + attribute_model: custom_attribute_type + ).find_by(attribute_key: query_hash[:attribute_key]) + + self.class::ATTRIBUTE_TYPES[custom_attribute&.attribute_display_type].to_s + end + + def valid_typed_values_for?(values, data_type, filter_operator) + Array.wrap(values).all? do |value| + valid_typed_value?(value, data_type, filter_operator) + end + end + + def text_filter_operator?(query_hash) + TEXT_FILTER_OPERATORS.include?(query_hash[:filter_operator]) + end + + def valid_typed_value?(value, data_type, filter_operator) + case data_type + when 'boolean' + BOOLEAN_VALUES.include?(value.to_s.downcase) + when 'date' + return Integer(value.to_s, exception: false).present? if filter_operator == DAYS_BEFORE_FILTER_OPERATOR + + Date.iso8601(value.to_s).present? + when 'numeric' + BigDecimal(value.to_s, exception: false).present? + when *TEXT_DATA_TYPES + value.is_a?(String) + else + Integer(value.to_s, exception: false).present? + end + rescue ArgumentError + false + end + + def unread_conversations + @account.conversations + .joins(:messages) + .merge(Message.incoming.reorder(nil)) + .where(messages: { account_id: @account.id }) + .where(unread_since_last_seen_condition) + .distinct + end + + def unread_since_last_seen_condition + conversations = Conversation.arel_table + messages = Message.arel_table + + conversations[:agent_last_seen_at].eq(nil).or(messages[:created_at].gt(conversations[:agent_last_seen_at])) + end +end diff --git a/app/services/conversations/unread_counts/filtered_count_instrumentation.rb b/app/services/conversations/unread_counts/filtered_count_instrumentation.rb new file mode 100644 index 000000000..707c63f33 --- /dev/null +++ b/app/services/conversations/unread_counts/filtered_count_instrumentation.rb @@ -0,0 +1,188 @@ +class Conversations::UnreadCounts::FilteredCountInstrumentation + # Centralizes the rollout-critical filtered unread count signals: + # API response duration, counter duration, snapshot build duration, snapshot state distribution, + # refresh claim rate, build lock acquisition rate, and invalidation/version bump rate. + EVENT_NAME = 'FilteredUnreadCounts'.freeze + METRIC_PREFIX = 'Custom/Conversations/UnreadCounts/Filtered'.freeze + SUMMARY_KEY = :filtered_unread_counts_request_summary + AGGREGATED_INCREMENT_OPERATIONS = %i[snapshot_state refresh_claim build_lock].freeze + SNAPSHOT_STATUSES = %i[fresh stale missing expired].freeze + SNAPSHOT_SCOPES = %i[built_in_filter folder_index filter].freeze + SUMMARY_DEFAULTS = begin + defaults = { + snapshot_total_count: 0, + refresh_claimed_count: 0, + refresh_skipped_count: 0, + build_lock_acquired_count: 0, + build_lock_missed_count: 0, + snapshot_build_success_count: 0, + snapshot_build_error_count: 0 + } + + SNAPSHOT_STATUSES.each { |status| defaults[:"snapshot_#{status}_count"] = 0 } + SNAPSHOT_SCOPES.each do |scope| + defaults[:"#{scope}_snapshot_count"] = 0 + defaults[:"#{scope}_refresh_claimed_count"] = 0 + defaults[:"#{scope}_refresh_skipped_count"] = 0 + defaults[:"#{scope}_build_lock_acquired_count"] = 0 + defaults[:"#{scope}_build_lock_missed_count"] = 0 + defaults[:"#{scope}_snapshot_build_success_count"] = 0 + defaults[:"#{scope}_snapshot_build_error_count"] = 0 + end + + defaults.freeze + end + private_constant :SUMMARY_KEY, :AGGREGATED_INCREMENT_OPERATIONS, :SNAPSHOT_STATUSES, :SNAPSHOT_SCOPES, :SUMMARY_DEFAULTS + + class << self + def summarize_request(account_id:) + previous_summary = current_summary + summary = request_summary(account_id) + Thread.current[SUMMARY_KEY] = summary + started_at = monotonic_time + status = :success + + yield + rescue StandardError => e + status = :error + summary[:error_class] = e.class.name + raise + ensure + record_request_summary(summary, status, started_at) + Thread.current[SUMMARY_KEY] = previous_summary + end + + def observe(operation, attributes = {}) + started_at = monotonic_time + + yield.tap do + record_observation(operation, attributes, started_at, status: :success) + end + rescue StandardError => e + record_observation(operation, attributes.merge(error_class: e.class.name), started_at, status: :error) + raise + end + + def increment(operation, attributes = {}) + record_increment_summary(operation, attributes) if aggregated_increment?(operation) + record_event(operation, attributes) unless aggregated_increment?(operation) + record_metric("#{metric_name(operation)}/count", 1) + end + + def record_event(operation, attributes = {}) + agent = new_relic_agent + return unless agent.respond_to?(:record_custom_event) + + agent.record_custom_event(EVENT_NAME, sanitized_attributes(attributes.merge(operation: operation))) + rescue StandardError + nil + end + + private + + def record_observation(operation, attributes, started_at, status:) + duration_ms = elapsed_ms_since(started_at) + record_observation_summary(operation, attributes, status: status) + record_metric("#{metric_name(operation)}/duration_ms", duration_ms) + end + + def record_request_summary(summary, status, started_at) + duration_ms = elapsed_ms_since(started_at) + summary[:status] = status + summary[:duration_ms] = duration_ms + record_metric("#{metric_name(:api_response)}/duration_ms", duration_ms) + record_event(:request_summary, summary) + end + + def record_metric(name, value) + agent = new_relic_agent + return unless agent.respond_to?(:record_metric) + + agent.record_metric(name, value) + rescue StandardError + nil + end + + def metric_name(operation) + "#{METRIC_PREFIX}/#{operation}" + end + + def request_summary(account_id) + SUMMARY_DEFAULTS.dup.merge(account_id: account_id) + end + + def current_summary + Thread.current[SUMMARY_KEY] + end + + def aggregated_increment?(operation) + operation.in?(AGGREGATED_INCREMENT_OPERATIONS) + end + + def record_increment_summary(operation, attributes) + case operation + when :snapshot_state + record_snapshot_state_summary(attributes) + when :refresh_claim + result = attributes[:claimed] ? :claimed : :skipped + increment_summary_count(:"refresh_#{result}_count") + increment_scoped_summary_count(attributes[:snapshot_scope], :"refresh_#{result}_count") + when :build_lock + result = attributes[:acquired] ? :acquired : :missed + increment_summary_count(:"build_lock_#{result}_count") + increment_scoped_summary_count(attributes[:snapshot_scope], :"build_lock_#{result}_count") + end + end + + def record_snapshot_state_summary(attributes) + increment_summary_count(:snapshot_total_count) + increment_summary_count(:"snapshot_#{attributes[:snapshot_status]}_count") + increment_scoped_summary_count(attributes[:snapshot_scope], :snapshot_count) + end + + def record_observation_summary(operation, attributes, status:) + return unless operation == :snapshot_build + + result = status == :success ? :success : :error + increment_summary_count(:"snapshot_build_#{result}_count") + increment_scoped_summary_count(attributes[:snapshot_scope], :"snapshot_build_#{result}_count") + end + + def increment_scoped_summary_count(scope, suffix) + return if scope.blank? + + increment_summary_count(:"#{scope}_#{suffix}") + end + + def increment_summary_count(key) + return if current_summary.blank? + + current_summary[key] = current_summary.fetch(key, 0) + 1 + end + + def sanitized_attributes(attributes) + attributes.compact.transform_values do |value| + case value + when String, Integer, Float, TrueClass, FalseClass + value + else + value.to_s + end + end + end + + def elapsed_ms_since(started_at) + ((monotonic_time - started_at) * 1000).round(2) + end + + def monotonic_time + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + def new_relic_agent + return unless defined?(::NewRelic::Agent) + + ::NewRelic::Agent + end + end +end diff --git a/app/services/conversations/unread_counts/filtered_count_invalidator.rb b/app/services/conversations/unread_counts/filtered_count_invalidator.rb new file mode 100644 index 000000000..6546bb58f --- /dev/null +++ b/app/services/conversations/unread_counts/filtered_count_invalidator.rb @@ -0,0 +1,184 @@ +class Conversations::UnreadCounts::FilteredCountInvalidator + FEATURE_FLAG = 'unread_count_for_filters'.freeze + + attr_reader :account + + def initialize(account) + @account = account + end + + def conversation_changed! + return false unless enabled? + + version = store.bump_conversation_version!(account.id) + record_invalidation(:conversation, reason: :conversation_changed, version: version) + true + end + + def user_visibility_changed!(user_id:) + return false unless enabled? && user_id.present? + + version = store.bump_built_in_filter_version!(account_id: account.id, user_id: user_id) + record_invalidation(:built_in_filter, reason: :user_visibility_changed, version: version) + true + end + + def users_visibility_changed!(user_ids:) + return false unless enabled? + + user_ids = Array(user_ids).compact_blank.uniq + return false if user_ids.blank? + + bump_built_in_filter_versions(user_ids).each_value do |version| + record_invalidation(:built_in_filter, reason: :user_visibility_changed, version: version) + end + true + end + + def custom_filter_created!(custom_filter) + return false unless conversation_filter?(custom_filter) + + bump_folder_index_version!(custom_filter, reason: :custom_filter_created) + bump_filter_version!(custom_filter, reason: :custom_filter_created) + true + end + + def custom_filter_updated!(custom_filter) + return false unless enabled? && conversation_filter_before_or_after?(custom_filter) + + filter_type_changed = filter_type_changed?(custom_filter) + query_changed = query_changed?(custom_filter) + return false unless filter_type_changed || query_changed + + bump_folder_index_version!(custom_filter, reason: :custom_filter_updated) if filter_type_changed + bump_filter_version!(custom_filter, reason: :custom_filter_updated) + store.delete_filter_count!(account_id: account.id, filter_id: custom_filter.id) if moved_out_of_conversation_filters?(custom_filter) + true + end + + def custom_filter_destroyed!(custom_filter) + return false unless conversation_filter?(custom_filter) + + bump_folder_index_version!(custom_filter, reason: :custom_filter_destroyed) + store.delete_filter_count!(account_id: account.id, filter_id: custom_filter.id) + true + end + + def custom_attribute_definition_changed!(custom_attribute_definition) + return false unless enabled? && conversation_attribute_before_or_after?(custom_attribute_definition) + + affected_filters = affected_custom_attribute_filters(custom_attribute_definition) + return false if affected_filters.blank? + + affected_filters.each { |custom_filter| bump_filter_version!(custom_filter, reason: :custom_attribute_definition_changed) } + true + end + + private + + def bump_built_in_filter_versions(user_ids) + results = Redis::Alfred.pipelined do |pipeline| + user_ids.each do |user_id| + key = store.built_in_filter_version_key(account.id, user_id) + pipeline.incr(key) + pipeline.expire(key, Conversations::UnreadCounts::FILTERED_COUNT_VERSION_TTL) + end + end + + user_ids.zip(results.each_slice(2).map(&:first)).to_h + end + + def enabled? + account&.feature_enabled?(FEATURE_FLAG) + end + + def conversation_filter?(custom_filter) + enabled? && custom_filter.conversation? + end + + def conversation_filter_before_or_after?(custom_filter) + custom_filter.conversation? || previous_filter_type(custom_filter) == 'conversation' + end + + def moved_out_of_conversation_filters?(custom_filter) + filter_type_changed?(custom_filter) && previous_filter_type(custom_filter) == 'conversation' && !custom_filter.conversation? + end + + def filter_type_changed?(custom_filter) + custom_filter.previous_changes.key?('filter_type') + end + + def query_changed?(custom_filter) + custom_filter.previous_changes.key?('query') + end + + def previous_filter_type(custom_filter) + raw_filter_type = custom_filter.previous_changes.dig('filter_type', 0) + return if raw_filter_type.blank? + return raw_filter_type if CustomFilter.filter_types.key?(raw_filter_type) + return CustomFilter.filter_types.key(raw_filter_type) if raw_filter_type.is_a?(Integer) + + CustomFilter.filter_types.key(raw_filter_type.to_i) || raw_filter_type.to_s + end + + def conversation_attribute_before_or_after?(custom_attribute_definition) + custom_attribute_definition.conversation_attribute? || previous_attribute_model(custom_attribute_definition) == 'conversation_attribute' + end + + def previous_attribute_model(custom_attribute_definition) + raw_attribute_model = custom_attribute_definition.previous_changes.dig('attribute_model', 0) + return if raw_attribute_model.blank? + return raw_attribute_model if CustomAttributeDefinition.attribute_models.key?(raw_attribute_model) + return CustomAttributeDefinition.attribute_models.key(raw_attribute_model) if raw_attribute_model.is_a?(Integer) + + CustomAttributeDefinition.attribute_models.key(raw_attribute_model.to_i) || raw_attribute_model.to_s + end + + def affected_custom_attribute_filters(custom_attribute_definition) + attribute_keys = custom_attribute_keys(custom_attribute_definition) + account.custom_filters.conversation.select do |custom_filter| + custom_filter_references_conversation_attribute?(custom_filter, attribute_keys) + end + end + + def custom_attribute_keys(custom_attribute_definition) + [custom_attribute_definition.attribute_key, custom_attribute_definition.previous_changes.dig('attribute_key', 0)].compact_blank.map(&:to_s).uniq + end + + def custom_filter_references_conversation_attribute?(custom_filter, attribute_keys) + payload = custom_filter.query.with_indifferent_access[:payload] + Array(payload).any? do |condition| + condition = condition.with_indifferent_access + condition[:attribute_key].to_s.in?(attribute_keys) && + (condition[:custom_attribute_type].presence || 'conversation_attribute') == 'conversation_attribute' + end + end + + def bump_folder_index_version!(custom_filter, reason:) + version = store.bump_folder_index_version!(account_id: account.id, user_id: custom_filter.user_id) + record_invalidation(:folder_index, reason: reason, version: version) + end + + def bump_filter_version!(custom_filter, reason:) + version = store.bump_filter_version!(account_id: account.id, filter_id: custom_filter.id) + record_invalidation(:filter, reason: reason, version: version) + end + + def record_invalidation(scope, reason:, version:) + instrumentation.increment( + :invalidation, + account_id: account.id, + invalidation_scope: scope, + reason: reason, + version: version + ) + end + + def store + ::Conversations::UnreadCounts::FilteredCountStore + end + + def instrumentation + ::Conversations::UnreadCounts::FilteredCountInstrumentation + end +end diff --git a/app/services/conversations/unread_counts/filtered_count_snapshot_resolver.rb b/app/services/conversations/unread_counts/filtered_count_snapshot_resolver.rb new file mode 100644 index 000000000..8685aa891 --- /dev/null +++ b/app/services/conversations/unread_counts/filtered_count_snapshot_resolver.rb @@ -0,0 +1,67 @@ +class Conversations::UnreadCounts::FilteredCountSnapshotResolver + BUILD_LOCK_TTL = 15.minutes.to_i + + attr_reader :account, :now, :store, :lock_manager + + def initialize(account:, now:, store:, lock_manager:) + @account = account + @now = now + @store = store + @lock_manager = lock_manager + end + + # Version mismatches make a snapshot stale immediately, but refresh_after keeps DB rebuilds throttled. + def resolve(scope:, state:, lock_key:, claim_refresh:, &) + record_snapshot_state(scope, state) + return state.payload if state.fresh? + + stale_payload = state.payload if state.stale? + return stale_payload if refresh_not_due?(stale_payload) + return stale_payload unless refresh_claimed?(scope, claim_refresh) + + build_with_lock(scope, lock_key, stale_payload, &) + end + + private + + def record_snapshot_state(scope, state) + instrumentation.increment( + :snapshot_state, + account_id: account.id, + snapshot_scope: scope, + snapshot_status: state.status + ) + end + + def refresh_not_due?(stale_payload) + stale_payload.present? && !store.refresh_due?(stale_payload, now: now) + end + + def refresh_claimed?(scope, claim_refresh) + claimed = claim_refresh.call + instrumentation.increment(:refresh_claim, account_id: account.id, snapshot_scope: scope, claimed: claimed) + claimed + end + + def build_with_lock(scope, lock_key, stale_payload, &) + built_payload = nil + lock_acquired = false + + begin + lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) do + lock_acquired = true + built_payload = instrumentation.observe(:snapshot_build, account_id: account.id, snapshot_scope: scope, &) + rescue ActiveRecord::StatementInvalid + built_payload = stale_payload + end + ensure + instrumentation.increment(:build_lock, account_id: account.id, snapshot_scope: scope, acquired: lock_acquired) + end + + lock_acquired ? built_payload : stale_payload + end + + def instrumentation + ::Conversations::UnreadCounts::FilteredCountInstrumentation + end +end diff --git a/app/services/conversations/unread_counts/filtered_count_store.rb b/app/services/conversations/unread_counts/filtered_count_store.rb new file mode 100644 index 000000000..b88e01627 --- /dev/null +++ b/app/services/conversations/unread_counts/filtered_count_store.rb @@ -0,0 +1,214 @@ +class Conversations::UnreadCounts::FilteredCountStore + extend Conversations::UnreadCounts::FilteredCountStoreKeys + + SnapshotResult = Struct.new(:status, :payload, keyword_init: true) do + def fresh? = status == :fresh + def stale? = status == :stale + def expired? = status == :expired + def missing? = status == :missing + end + + VERSION_KEY_METHODS = { + conversation: :conversation_version_key, + built_in_filter: :built_in_filter_version_key, + folder_index: :folder_index_version_key, + filter: :filter_version_key + }.freeze + REFRESH_THROTTLE_KEY_METHODS = { + built_in_filter: :built_in_filter_refresh_throttle_key, + folder_index: :folder_index_refresh_throttle_key, + filter: :filter_refresh_throttle_key + }.freeze + private_constant :VERSION_KEY_METHODS, :REFRESH_THROTTLE_KEY_METHODS + + class << self + def conversation_version(account_id) = current_version_for(:conversation, account_id) + def built_in_filter_version(account_id:, user_id:) = current_version_for(:built_in_filter, account_id, user_id) + def folder_index_version(account_id:, user_id:) = current_version_for(:folder_index, account_id, user_id) + def filter_version(account_id:, filter_id:) = current_version_for(:filter, account_id, filter_id) + + def bump_conversation_version!(account_id) = bump_version_for!(:conversation, account_id) + def bump_built_in_filter_version!(account_id:, user_id:) = bump_version_for!(:built_in_filter, account_id, user_id) + def bump_folder_index_version!(account_id:, user_id:) = bump_version_for!(:folder_index, account_id, user_id) + def bump_filter_version!(account_id:, filter_id:) = bump_version_for!(:filter, account_id, filter_id) + + # Keep version dimensions explicit so callers cannot write a snapshot without the freshness contract it depends on. + def write_built_in_filter_counts!(account_id:, user_id:, counts:, account_version:, built_in_filter_version:, built_at: Time.current, meta: {}) # rubocop:disable Metrics/ParameterLists + payload = snapshot_payload(built_at).merge( + account_version: account_version, + built_in_filter_version: built_in_filter_version, + user_id: user_id, + counts: counts, + meta: meta + ) + write_snapshot(built_in_filter_counts_key(account_id, user_id), payload) + end + + def built_in_filter_counts(account_id:, user_id:) + read_snapshot(built_in_filter_counts_key(account_id, user_id)) + end + + def built_in_filter_counts_state(account_id:, user_id:, versions: nil, now: Time.current) + snapshot_state( + built_in_filter_counts(account_id: account_id, user_id: user_id), + versions: versions || { + account_version: conversation_version(account_id), + built_in_filter_version: built_in_filter_version(account_id: account_id, user_id: user_id) + }, + now: now + ) + end + + def write_folder_index!(account_id:, user_id:, filter_ids:, folder_index_version:, built_at: Time.current) + payload = snapshot_payload(built_at).merge( + folder_index_version: folder_index_version, + user_id: user_id, + filter_ids: Array(filter_ids).map(&:to_i) + ) + write_snapshot(folder_index_key(account_id, user_id), payload) + end + + def folder_index(account_id:, user_id:) + read_snapshot(folder_index_key(account_id, user_id)) + end + + def folder_index_state(account_id:, user_id:, versions: nil, now: Time.current) + snapshot_state( + folder_index(account_id: account_id, user_id: user_id), + versions: versions || { folder_index_version: folder_index_version(account_id: account_id, user_id: user_id) }, + now: now + ) + end + + # Saved folder snapshots depend on account, filter, and owner visibility versions; keep all three visible at the callsite. + def write_filter_count!(account_id:, filter_id:, user_id:, count:, account_version:, filter_version:, owner_built_in_filter_version:, # rubocop:disable Metrics/ParameterLists + built_at: Time.current, meta: {}) + payload = snapshot_payload(built_at).merge( + account_version: account_version, + filter_version: filter_version, + owner_built_in_filter_version: owner_built_in_filter_version, + filter_id: filter_id, + user_id: user_id, + count: count, + meta: meta + ) + write_snapshot(filter_count_key(account_id, filter_id), payload) + end + + def filter_count(account_id:, filter_id:) + read_snapshot(filter_count_key(account_id, filter_id)) + end + + def filter_count_state(account_id:, filter_id:, owner_user_id: nil, versions: nil, now: Time.current) + snapshot = filter_count(account_id: account_id, filter_id: filter_id) + return SnapshotResult.new(status: :missing, payload: nil) if snapshot.blank? + + owner_user_id ||= snapshot[:user_id] + snapshot_state( + snapshot, + versions: versions || { + account_version: conversation_version(account_id), + filter_version: filter_version(account_id: account_id, filter_id: filter_id), + owner_built_in_filter_version: built_in_filter_version(account_id: account_id, user_id: owner_user_id) + }, + now: now + ) + end + + def refresh_due?(snapshot, now: Time.current) + return true if snapshot.blank? + + refresh_after = parse_time(snapshot[:refresh_after]) + refresh_after.blank? || now >= refresh_after + end + + def claim_built_in_filter_refresh!(account_id:, user_id:) = claim_refresh_for!(:built_in_filter, account_id, user_id) + def claim_folder_index_refresh!(account_id:, user_id:) = claim_refresh_for!(:folder_index, account_id, user_id) + def claim_filter_refresh!(account_id:, filter_id:) = claim_refresh_for!(:filter, account_id, filter_id) + + def delete_filter_count!(account_id:, filter_id:) + Redis::Alfred.delete(filter_count_key(account_id, filter_id)) + end + + private + + # Keep the public API domain-specific while centralizing direct Redis version/throttle operations. + def current_version_for(scope, *key_args) + current_version(public_send(VERSION_KEY_METHODS.fetch(scope), *key_args)) + end + + def bump_version_for!(scope, *key_args) + key = public_send(VERSION_KEY_METHODS.fetch(scope), *key_args) + + Redis::Alfred.with do |conn| + conn.multi do |transaction| + transaction.incr(key) + transaction.expire(key, Conversations::UnreadCounts::FILTERED_COUNT_VERSION_TTL) + end.first + end + end + + def claim_refresh_for!(scope, *key_args) + claim_refresh_throttle(public_send(REFRESH_THROTTLE_KEY_METHODS.fetch(scope), *key_args)) + end + + def current_version(key) + Redis::Alfred.get(key).to_i + end + + def snapshot_payload(built_at) + # expires_at marks the end of the fresh window. Redis keeps the snapshot for the additional stale window. + { + built_at: built_at.iso8601, + refresh_after: (built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL).iso8601, + expires_at: (built_at + Conversations::UnreadCounts::FILTERED_COUNT_FRESH_TTL).iso8601 + } + end + + def write_snapshot(key, payload) + Redis::Alfred.setex(key, JSON.generate(payload), Conversations::UnreadCounts::FILTERED_COUNT_REDIS_TTL) + end + + def read_snapshot(key) + value = Redis::Alfred.get(key) + return if value.blank? + + JSON.parse(value, symbolize_names: true) + end + + def snapshot_state(snapshot, versions:, now:) + return SnapshotResult.new(status: :missing, payload: nil) if snapshot.blank? + return SnapshotResult.new(status: :expired, payload: snapshot) unless inside_stale_window?(snapshot, now) + return SnapshotResult.new(status: :fresh, payload: snapshot) if versions_match?(snapshot, versions) && inside_fresh_window?(snapshot, now) + + SnapshotResult.new(status: :stale, payload: snapshot) + end + + def versions_match?(snapshot, versions) + versions.all? { |key, value| snapshot[key].to_i == value.to_i } + end + + def inside_fresh_window?(snapshot, now) + expires_at = parse_time(snapshot[:expires_at]) + expires_at.present? && now <= expires_at + end + + def inside_stale_window?(snapshot, now) + expires_at = parse_time(snapshot[:expires_at]) + expires_at.present? && now <= expires_at + Conversations::UnreadCounts::FILTERED_COUNT_STALE_WINDOW + end + + def parse_time(value) + return value if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone) + return value.to_time if value.respond_to?(:to_time) && !value.is_a?(String) + + Time.zone.parse(value.to_s) + rescue ArgumentError, TypeError + nil + end + + def claim_refresh_throttle(key) + Redis::Alfred.set(key, Time.current.to_i, nx: true, ex: Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL) ? true : false + end + end +end diff --git a/app/services/conversations/unread_counts/filtered_count_store_keys.rb b/app/services/conversations/unread_counts/filtered_count_store_keys.rb new file mode 100644 index 000000000..5072503eb --- /dev/null +++ b/app/services/conversations/unread_counts/filtered_count_store_keys.rb @@ -0,0 +1,67 @@ +module Conversations::UnreadCounts::FilteredCountStoreKeys + def conversation_version_key(account_id) + account_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_CONVERSATION_VERSION, account_id) + end + + def built_in_filter_version_key(account_id, user_id) + user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_VERSION, account_id, user_id) + end + + def built_in_filter_counts_key(account_id, user_id) + user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_COUNTS, account_id, user_id) + end + + def built_in_filter_build_lock_key(account_id, user_id) + user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_BUILD_LOCK, account_id, user_id) + end + + def built_in_filter_refresh_throttle_key(account_id, user_id) + user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_REFRESH_THROTTLE, account_id, user_id) + end + + def folder_index_version_key(account_id, user_id) + user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FOLDER_INDEX_VERSION, account_id, user_id) + end + + def folder_index_key(account_id, user_id) + user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FOLDER_INDEX, account_id, user_id) + end + + def folder_index_build_lock_key(account_id, user_id) + user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FOLDER_INDEX_BUILD_LOCK, account_id, user_id) + end + + def folder_index_refresh_throttle_key(account_id, user_id) + user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FOLDER_INDEX_REFRESH_THROTTLE, account_id, user_id) + end + + def filter_version_key(account_id, filter_id) + filter_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FILTER_VERSION, account_id, filter_id) + end + + def filter_count_key(account_id, filter_id) + filter_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FILTER_COUNT, account_id, filter_id) + end + + def filter_build_lock_key(account_id, filter_id) + filter_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FILTER_BUILD_LOCK, account_id, filter_id) + end + + def filter_refresh_throttle_key(account_id, filter_id) + filter_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FILTER_REFRESH_THROTTLE, account_id, filter_id) + end + + private + + def account_key(format_string, account_id) + format(format_string, account_id: account_id) + end + + def user_key(format_string, account_id, user_id) + format(format_string, account_id: account_id, user_id: user_id) + end + + def filter_key(format_string, account_id, filter_id) + format(format_string, account_id: account_id, filter_id: filter_id) + end +end diff --git a/app/services/conversations/unread_counts/filtered_count_version_cache.rb b/app/services/conversations/unread_counts/filtered_count_version_cache.rb new file mode 100644 index 000000000..5aa73269a --- /dev/null +++ b/app/services/conversations/unread_counts/filtered_count_version_cache.rb @@ -0,0 +1,29 @@ +class Conversations::UnreadCounts::FilteredCountVersionCache + attr_reader :account, :user, :store + + def initialize(account:, user:, store:) + @account = account + @user = user + @store = store + end + + def built_in_filter = { account_version: account_version, built_in_filter_version: built_in_filter_version } + + def folder_index = { folder_index_version: store.folder_index_version(account_id: account.id, user_id: user.id) } + + def filter(filter_id) + { + account_version: account_version, + filter_version: filter_version(filter_id), + owner_built_in_filter_version: built_in_filter_version + } + end + + private + + def account_version = @account_version ||= store.conversation_version(account.id) + + def built_in_filter_version = @built_in_filter_version ||= store.built_in_filter_version(account_id: account.id, user_id: user.id) + + def filter_version(filter_id) = (@filter_versions ||= {})[filter_id] ||= store.filter_version(account_id: account.id, filter_id: filter_id) +end diff --git a/app/services/conversations/unread_counts/filtered_counter.rb b/app/services/conversations/unread_counts/filtered_counter.rb new file mode 100644 index 000000000..1194951b6 --- /dev/null +++ b/app/services/conversations/unread_counts/filtered_counter.rb @@ -0,0 +1,216 @@ +class Conversations::UnreadCounts::FilteredCounter + FEATURE_FLAG = 'unread_count_for_filters'.freeze + EMPTY_COUNTS = { + mentions_count: 0, + participating_count: 0, + unattended_count: 0, + folders: {} + }.freeze + + attr_reader :account, :user, :now + + def self.empty_counts = EMPTY_COUNTS.deep_dup + + def initialize(account:, user:, now: Time.current) + @account = account + @user = user + @now = now + end + + def perform = instrumentation.observe(:counter_perform, account_id: account.id) { built_in_counts.merge(folders: folder_counts) } + + private + + def built_in_counts = counts_from_built_in_snapshot(built_in_counts_snapshot) || self.class.empty_counts.except(:folders) + + def built_in_counts_snapshot + versions = version_cache.built_in_filter + snapshot_or_build( + scope: :built_in_filter, + state: store.built_in_filter_counts_state(account_id: account.id, user_id: user.id, versions: versions, now: now), + lock_key: store.built_in_filter_build_lock_key(account.id, user.id), + claim_refresh: -> { store.claim_built_in_filter_refresh!(account_id: account.id, user_id: user.id) } + ) { build_built_in_counts!(versions) } + end + + def counts_from_built_in_snapshot(snapshot) = snapshot&.fetch(:counts, nil)&.slice(:mentions_count, :participating_count, :unattended_count) + + def folder_counts + folder_index = folder_index_snapshot + return {} if folder_index.blank? + + @inline_filter_builds = 0 + folder_index[:filter_ids].each_with_object({}) do |filter_id, counts| + count = filter_count(filter_id) + counts[filter_id.to_s] = count if count.to_i.positive? + end + end + + def folder_index_snapshot + versions = version_cache.folder_index + snapshot_or_build( + scope: :folder_index, + state: store.folder_index_state(account_id: account.id, user_id: user.id, versions: versions, now: now), + lock_key: store.folder_index_build_lock_key(account.id, user.id), + claim_refresh: -> { store.claim_folder_index_refresh!(account_id: account.id, user_id: user.id) } + ) { build_folder_index!(versions) } + end + + def filter_count(filter_id) + versions = version_cache.filter(filter_id) + snapshot = snapshot_or_build( + scope: :filter, + state: store.filter_count_state(account_id: account.id, filter_id: filter_id, owner_user_id: user.id, versions: versions, now: now), + lock_key: store.filter_build_lock_key(account.id, filter_id), + claim_refresh: -> { filter_build_available? && store.claim_filter_refresh!(account_id: account.id, filter_id: filter_id) } + ) do + track_filter_build! + build_filter_count!(filter_id, versions) + end + + snapshot&.fetch(:count, nil) + end + + def snapshot_or_build(scope:, state:, lock_key:, claim_refresh:, &) + snapshot_resolver.resolve(scope: scope, state: state, lock_key: lock_key, claim_refresh: claim_refresh, &) + end + + def filter_build_available? = @inline_filter_builds.to_i < Conversations::UnreadCounts::MAX_INLINE_FILTER_BUILDS + + def track_filter_build! = @inline_filter_builds = @inline_filter_builds.to_i + 1 + + def build_built_in_counts!(versions) + store.write_built_in_filter_counts!(**built_in_count_snapshot_payload(versions)) + store.built_in_filter_counts(account_id: account.id, user_id: user.id) + end + + def built_in_count_snapshot_payload(versions) + { + account_id: account.id, + user_id: user.id, + counts: built_in_counts_from_database, + account_version: versions.fetch(:account_version), + built_in_filter_version: versions.fetch(:built_in_filter_version), + built_at: now + } + end + + def built_in_counts_from_database + { + mentions_count: count_relation(mentioned_unread_conversations), + participating_count: count_relation(participating_unread_conversations), + unattended_count: count_relation(unread_open_accessible_conversations.unattended) + } + end + + def mentioned_unread_conversations + unread_open_accessible_conversations + .joins(:mentions) + .where(mentions: { account_id: account.id, user_id: user.id }) + end + + def participating_unread_conversations + unread_open_accessible_conversations + .joins(:conversation_participants) + .where(conversation_participants: { user_id: user.id }) + end + + def build_folder_index!(versions) + store.write_folder_index!( + account_id: account.id, + user_id: user.id, + filter_ids: folder_filter_ids_from_database, + folder_index_version: versions.fetch(:folder_index_version), + built_at: now + ) + store.folder_index(account_id: account.id, user_id: user.id) + end + + def folder_filter_ids_from_database = account.custom_filters.where(user_id: user.id, filter_type: :conversation).pluck(:id) + + def build_filter_count!(filter_id, versions) + custom_filter = account.custom_filters.find_by(id: filter_id, user_id: user.id, filter_type: :conversation) + return delete_filter_count!(filter_id) if custom_filter.blank? + + count = filter_query_count(custom_filter) + return delete_filter_count!(filter_id) if count.nil? + + write_filter_count!(filter_id, count, versions) + store.filter_count(account_id: account.id, filter_id: filter_id) + rescue CustomExceptions::CustomFilter::InvalidAttribute, + CustomExceptions::CustomFilter::InvalidOperator, + CustomExceptions::CustomFilter::InvalidQueryOperator, + CustomExceptions::CustomFilter::InvalidValue + delete_filter_count!(filter_id) + end + + def filter_query_count(custom_filter) + ::Conversations::UnreadCounts::FilterQueryCounter.new( + account: account, + user: user, + query: custom_filter.query + ).perform + end + + def write_filter_count!(filter_id, count, versions) + store.write_filter_count!( + account_id: account.id, + filter_id: filter_id, + user_id: user.id, + count: count, + account_version: versions.fetch(:account_version), + filter_version: versions.fetch(:filter_version), + owner_built_in_filter_version: versions.fetch(:owner_built_in_filter_version), + built_at: now + ) + end + + def version_cache = @version_cache ||= ::Conversations::UnreadCounts::FilteredCountVersionCache.new(account: account, user: user, store: store) + + def delete_filter_count!(filter_id) = store.delete_filter_count!(account_id: account.id, filter_id: filter_id).then { nil } + + def unread_open_accessible_conversations + @unread_open_accessible_conversations ||= Conversations::PermissionFilterService.new( + unread_conversations.open, + user, + account + ).perform + end + + def unread_conversations + account.conversations + .joins(:messages) + .merge(Message.incoming.reorder(nil)) + .where(messages: { account_id: account.id }) + .where(unread_since_last_seen_condition) + .distinct + end + + def unread_since_last_seen_condition + conversations = Conversation.arel_table + messages = Message.arel_table + + conversations[:agent_last_seen_at].eq(nil).or(messages[:created_at].gt(conversations[:agent_last_seen_at])) + end + + def count_relation(relation) = relation.unscope(:order).count + + def lock_manager = @lock_manager ||= Redis::LockManager.new + + def snapshot_resolver + @snapshot_resolver ||= ::Conversations::UnreadCounts::FilteredCountSnapshotResolver.new( + account: account, + now: now, + store: store, + lock_manager: lock_manager + ) + end + + def store + ::Conversations::UnreadCounts::FilteredCountStore + end + + def instrumentation + ::Conversations::UnreadCounts::FilteredCountInstrumentation + end +end diff --git a/app/services/conversations/unread_counts/listener.rb b/app/services/conversations/unread_counts/listener.rb index 28792d884..59f15f5bc 100644 --- a/app/services/conversations/unread_counts/listener.rb +++ b/app/services/conversations/unread_counts/listener.rb @@ -1,34 +1,59 @@ class Conversations::UnreadCounts::Listener < BaseListener include Events::Types + FILTERED_CONVERSATION_UPDATE_KEYS = %w[ + additional_attributes cached_label_list campaign_id custom_attributes first_reply_created_at label_list last_activity_at priority snoozed_until + waiting_since + ].freeze + private_constant :FILTERED_CONVERSATION_UPDATE_KEYS + def message_created(event) message, = extract_message_and_account(event) - return unless message.incoming? - return unless message.account.feature_enabled?('conversation_unread_counts') + account = message.account + return unless account.feature_enabled?('conversation_unread_counts') || account.feature_enabled?(filtered_count_feature_flag) - refresh(message.conversation) + conversation = message.conversation + refreshed = refresh(conversation) if message.incoming? && account.feature_enabled?('conversation_unread_counts') + + invalidate_filtered_conversation(conversation) + + notify_filtered_count_change(conversation) unless message.incoming? && refreshed end def conversation_status_changed(event) conversation, = extract_conversation_and_account(event) - refresh(conversation, event.data[:changed_attributes]) + refresh_then_invalidate(conversation, event.data[:changed_attributes]) end def conversation_updated(event) - return unless label_changed?(event.data[:changed_attributes]) - conversation, = extract_conversation_and_account(event) - refresh(conversation, event.data[:changed_attributes]) + changed_attributes = event.data[:changed_attributes] + notify_filtered_count_change(conversation) if filtered_conversation_update_changed?(changed_attributes) && !label_changed?(changed_attributes) + return unless label_changed?(changed_attributes) + + refresh(conversation, changed_attributes) + end + + def conversation_contact_changed(event) + conversation, = extract_conversation_and_account(event) + invalidate_filtered_conversation(conversation) + notify_filtered_count_change(conversation) end def assignee_changed(event) conversation, = extract_conversation_and_account(event) - refresh(conversation, event.data[:changed_attributes]) + refresh_then_invalidate(conversation, event.data[:changed_attributes]) end def team_changed(event) conversation, = extract_conversation_and_account(event) - refresh(conversation, event.data[:changed_attributes]) + refresh_then_invalidate(conversation, event.data[:changed_attributes]) + end + + def conversation_mentioned(event) + conversation, = extract_conversation_and_account(event) + user = event.data[:user] + filtered_count_invalidator(conversation.account).user_visibility_changed!(user_id: user&.id) end def conversation_deleted(event) @@ -36,14 +61,23 @@ class Conversations::UnreadCounts::Listener < BaseListener return if conversation_data.blank? account = Account.find_by(id: conversation_data[:account_id]) - return unless account&.feature_enabled?('conversation_unread_counts') - return unless remove_deleted_conversation(account, conversation_data) + return if account.blank? + + removed = account.feature_enabled?('conversation_unread_counts') && remove_deleted_conversation(account, conversation_data) + filtered_count_invalidator(account).conversation_changed! + return notify_deleted_filtered_count_change(account, conversation_data) unless removed Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h) end private + def refresh_then_invalidate(conversation, changed_attributes = nil) + refreshed = refresh(conversation, changed_attributes) + invalidate_filtered_conversation(conversation) + notify_filtered_count_change(conversation) unless refreshed + end + def refresh(conversation, changed_attributes = nil) ::Conversations::UnreadCounts::Notifier.new(conversation, changed_attributes: changed_attributes).perform end @@ -90,6 +124,37 @@ class Conversations::UnreadCounts::Listener < BaseListener changed_attributes.key?('cached_label_list') || changed_attributes.key?(:cached_label_list) end + def filtered_conversation_update_changed?(changed_attributes) + return false if changed_attributes.blank? + + changed_attributes.keys.map(&:to_s).intersect?(FILTERED_CONVERSATION_UPDATE_KEYS) + end + + def invalidate_filtered_conversation(conversation) + filtered_count_invalidator(conversation.account).conversation_changed! + end + + def notify_filtered_count_change(conversation) + return unless conversation.account.feature_enabled?('conversation_unread_counts') + return unless conversation.account.feature_enabled?(filtered_count_feature_flag) + + Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation) + end + + def notify_deleted_filtered_count_change(account, conversation_data) + return unless account.feature_enabled?(filtered_count_feature_flag) + + Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h) + end + + def filtered_count_invalidator(account) + ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account) + end + + def filtered_count_feature_flag + ::Conversations::UnreadCounts::FilteredCountInvalidator::FEATURE_FLAG + end + def store ::Conversations::UnreadCounts::Store end diff --git a/app/services/conversations/unread_counts/notifier.rb b/app/services/conversations/unread_counts/notifier.rb index 652fbde3a..6075b4abc 100644 --- a/app/services/conversations/unread_counts/notifier.rb +++ b/app/services/conversations/unread_counts/notifier.rb @@ -10,10 +10,20 @@ class Conversations::UnreadCounts::Notifier def perform return false unless conversation.account.feature_enabled?('conversation_unread_counts') + return dispatch_unread_count_changed if ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform + return false unless conversation.account.feature_enabled?(filtered_count_feature_flag) - return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform + dispatch_unread_count_changed + end + private + + def dispatch_unread_count_changed Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation) true end + + def filtered_count_feature_flag + ::Conversations::UnreadCounts::FilteredCountInvalidator::FEATURE_FLAG + end end diff --git a/app/services/crm/base_processor_service.rb b/app/services/crm/base_processor_service.rb index 305a09014..f7e4aece1 100644 --- a/app/services/crm/base_processor_service.rb +++ b/app/services/crm/base_processor_service.rb @@ -78,6 +78,14 @@ class Crm::BaseProcessorService contact.save! end + def clear_external_id(contact) + return if contact.additional_attributes.blank? + return if contact.additional_attributes['external'].blank? + + contact.additional_attributes['external'].delete("#{crm_name}_id") + contact.save! + end + def store_conversation_metadata(conversation, metadata) # Initialize additional_attributes if it's nil conversation.additional_attributes = {} if conversation.additional_attributes.nil? diff --git a/app/services/crm/leadsquared/processor_service.rb b/app/services/crm/leadsquared/processor_service.rb index 9ffa3d12c..e8e30cdd4 100644 --- a/app/services/crm/leadsquared/processor_service.rb +++ b/app/services/crm/leadsquared/processor_service.rb @@ -64,7 +64,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService # may not be marked as unique, same with the phone number field # So we just use the update API if we already have a lead ID if lead_id.present? - @lead_client.update_lead(lead_data, lead_id) + with_stale_lead_recovery(contact, lead_id) { |id| @lead_client.update_lead(lead_data, id) } else new_lead_id = @lead_client.create_or_update_lead(lead_data) store_external_id(contact, new_lead_id) @@ -82,7 +82,9 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService return if lead_id.blank? activity_code = get_activity_code(activity_code_key) - activity_id = @activity_client.post_activity(lead_id, activity_code, activity_note) + activity_id = with_stale_lead_recovery(conversation.contact, lead_id) do |id| + @activity_client.post_activity(id, activity_code, activity_note) + end return if activity_id.blank? metadata = {} @@ -94,6 +96,31 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService log_activity_error(e, activity_type, conversation) end + # The cached lead id can become stale when the lead is deleted/merged in LeadSquared, + # making LeadSquared reject the call with "Lead not found". When that happens, clear the + # stored id, re-resolve the contact to a fresh lead, and run the operation again once. + def with_stale_lead_recovery(contact, lead_id) + yield(lead_id) + rescue Crm::Leadsquared::Api::BaseClient::ApiError => e + raise unless lead_not_found_error?(e) + + Rails.logger.warn("LeadSquared stale lead #{lead_id} for contact ##{contact.id}, clearing and retrying") + clear_external_id(contact) + fresh_lead_id = get_lead_id(contact) + raise if fresh_lead_id.blank? || fresh_lead_id == lead_id + + yield(fresh_lead_id) + end + + def lead_not_found_error?(error) + return false if error.response.blank? + + parsed = error.response.parsed_response + parsed.is_a?(Hash) && parsed['ExceptionType'] == 'MXInvalidEntityReferenceException' + rescue StandardError + false + end + def log_activity_error(error, activity_type, conversation, payload: nil) ChatwootExceptionTracker.new(error, account: @account).capture_exception context = "account_id=#{conversation.account_id}, conversation_display_id=#{conversation.display_id}" @@ -116,7 +143,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService unless identifiable_contact?(contact) Rails.logger.info("Contact not identifiable. Skipping activity for ##{contact.id}") - nil + return nil end lead_id = @lead_finder.find_or_create(contact) diff --git a/app/services/data_imports/intercom/activity_content_builder.rb b/app/services/data_imports/intercom/activity_content_builder.rb new file mode 100644 index 000000000..6e56ff090 --- /dev/null +++ b/app/services/data_imports/intercom/activity_content_builder.rb @@ -0,0 +1,84 @@ +class DataImports::Intercom::ActivityContentBuilder + EVENT_KEYS = { + 'assignment' => :assignment, + 'assign_and_reopen' => :assign_and_reopen, + 'open' => :open, + 'close' => :close, + 'snoozed' => :snoozed, + 'participant_added' => :participant_added, + 'participant_removed' => :participant_removed, + 'conversation_attribute_updated_by_admin' => :conversation_attribute_updated, + 'conversation_attribute_updated_by_user' => :conversation_attribute_updated, + 'conversation_attribute_updated_by_workflow' => :conversation_attribute_updated, + 'ticket_attribute_updated_by_admin' => :ticket_attribute_updated, + 'ticket_state_updated_by_admin' => :ticket_state_updated, + 'custom_action_started' => :custom_action_started, + 'custom_action_finished' => :custom_action_finished, + 'quick_reply' => :quick_reply + }.freeze + + def initialize(part) + @part = part.to_h + end + + def perform + append_body(translated_content) + end + + private + + def translated_content + key = EVENT_KEYS.fetch(event_type, :generic) + key = "#{key}_with_target" if target_aware_event?(key) && target_name.present? + + I18n.t( + "data_imports.intercom.activities.#{key}", + actor: actor_name, + target: target_name, + event: event_type.tr('_', ' ') + ) + end + + def event_type + @part['part_type'].to_s + end + + def actor_name + author = @part['author'].to_h + return author['name'] if author['name'].present? + + case author['type'] + when 'user', 'contact', 'lead' + 'Contact' + when 'bot' + 'Intercom automation' + else + automation_event? ? 'Intercom automation' : 'Intercom teammate' + end + end + + def target_name + assigned_to = @part['assigned_to'].to_h + event_details = @part['event_details'].to_h + participant = event_details['participant'].to_h + + assigned_to['name'].presence || participant['name'].presence || event_details['participant_name'].presence || event_details['name'].presence + end + + def target_aware_event?(key) + %i[assignment assign_and_reopen participant_added participant_removed].include?(key) + end + + def automation_event? + event_type.include?('workflow') || event_type.start_with?('custom_action') + end + + def append_body(content) + fragment = Nokogiri::HTML5.fragment(@part['body'].to_s) + fragment.css('script, style').remove + body = fragment.text.squish + return content if body.blank? || content.downcase.include?(body.downcase) + + "#{content}: #{body}" + end +end diff --git a/app/services/data_imports/intercom/client.rb b/app/services/data_imports/intercom/client.rb new file mode 100644 index 000000000..35193d22c --- /dev/null +++ b/app/services/data_imports/intercom/client.rb @@ -0,0 +1,107 @@ +class DataImports::Intercom::Client + class Error < StandardError + attr_reader :status, :body + + def initialize(message, status: nil, body: nil) + super(message) + @status = status + @body = body + end + end + + class AuthenticationError < Error; end + + class RateLimitError < Error + attr_reader :retry_after + + def initialize(message, retry_after: nil, **) + super(message, **) + @retry_after = retry_after + end + end + + BASE_URL = 'https://api.intercom.io'.freeze + API_VERSION = '2.15'.freeze + DEFAULT_PER_PAGE = 50 + + def initialize(access_token:) + @access_token = access_token + end + + def list_contacts(starting_after: nil, per_page: DEFAULT_PER_PAGE) + get('/contacts', query: pagination_query(starting_after, per_page)) + end + + def list_conversations(starting_after: nil, per_page: DEFAULT_PER_PAGE) + get('/conversations', query: pagination_query(starting_after, per_page)) + end + + def retrieve_conversation(id) + get("/conversations/#{id}") + end + + def retrieve_contact(id) + get("/contacts/#{id}") + end + + private + + def pagination_query(starting_after, per_page) + { per_page: per_page, starting_after: starting_after }.compact + end + + def get(path, query: {}) + response = + begin + HTTParty.get( + "#{BASE_URL}#{path}", + query: query, + headers: headers, + timeout: 30 + ) + rescue StandardError => e + raise Error.new( + "Intercom API request failed before receiving a response: #{e.message}", + body: { transport_error_class: e.class.name } + ) + end + + parse_response(response) + end + + def headers + { + 'Authorization' => "Bearer #{@access_token}", + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Intercom-Version' => API_VERSION + } + end + + def parse_response(response) + body = parsed_body(response) + return body if response.success? + + message = error_message(body, response) + case response.code + when 401, 403 + raise AuthenticationError.new(message, status: response.code, body: body) + when 429 + raise RateLimitError.new(message, status: response.code, body: body, retry_after: response.headers['retry-after']) + else + raise Error.new(message, status: response.code, body: body) + end + end + + def parsed_body(response) + response.parsed_response.presence || {} + rescue JSON::ParserError + {} + end + + def error_message(body, response) + errors = body.is_a?(Hash) ? body['errors'] : nil + first_error = errors.is_a?(Array) ? errors.first : nil + first_error&.dig('message').presence || "Intercom API request failed with status #{response.code}" + end +end diff --git a/app/services/data_imports/intercom/creation_service.rb b/app/services/data_imports/intercom/creation_service.rb new file mode 100644 index 000000000..401bbbeb4 --- /dev/null +++ b/app/services/data_imports/intercom/creation_service.rb @@ -0,0 +1,67 @@ +class DataImports::Intercom::CreationService + def initialize(account:, initiated_by:, source_params:) + @account = account + @initiated_by = initiated_by + @source_params = source_params.symbolize_keys + @access_token = @source_params[:access_token].to_s.strip + end + + def perform + return if active_import? + + totals = validate_source + @account.with_lock do + next if active_import? + + @account.data_imports.new(attributes(totals)).tap do |data_import| + data_import.assign_active_intercom_import_run_id + data_import.save! + end + end + end + + private + + def validate_source + raise ArgumentError, 'Unsupported import source.' unless @source_params[:source_provider] == 'intercom' + + DataImports::Intercom::CredentialsValidator.new( + access_token: @access_token, + import_types: import_types + ).perform + end + + def attributes(totals) + { + name: @source_params[:name].presence || 'Intercom import', + data_type: 'intercom', + source_type: 'api', + source_provider: 'intercom', + import_types: import_types, + initiated_by: @initiated_by, + access_token: @access_token, + stats: initial_stats(totals) + } + end + + def import_types + return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless @source_params.key?(:import_types) + + Array(@source_params[:import_types]).compact_blank + end + + def initial_stats(totals) + { + 'contacts' => { 'imported' => 0, 'skipped' => 0 }, + 'conversations' => { 'imported' => 0, 'skipped' => 0 }, + 'messages' => { 'imported' => 0, 'skipped' => 0 }, + 'errors' => { 'count' => 0 } + }.tap do |stats| + totals.each { |type, total| stats[type]['total'] = total unless total.nil? } + end + end + + def active_import? + @account.data_imports.active_intercom.exists? + end +end diff --git a/app/services/data_imports/intercom/credentials_validator.rb b/app/services/data_imports/intercom/credentials_validator.rb new file mode 100644 index 000000000..10391033e --- /dev/null +++ b/app/services/data_imports/intercom/credentials_validator.rb @@ -0,0 +1,36 @@ +class DataImports::Intercom::CredentialsValidator + def initialize(access_token:, import_types:) + @access_token = access_token.to_s.strip + @import_types = Array(import_types).compact_blank + end + + def perform + validate_parameters! + + {}.tap do |totals| + contacts_response = client.list_contacts(per_page: 1) if @import_types.intersect?(%w[contacts conversations]) + totals['contacts'] = total_count(contacts_response) if @import_types.include?('contacts') + totals['conversations'] = total_count(client.list_conversations(per_page: 1)) if @import_types.include?('conversations') + end.compact + end + + private + + def validate_parameters! + raise ArgumentError, 'Intercom access key is required.' if @access_token.blank? + raise ArgumentError, 'Select at least one data type to import.' if @import_types.blank? + + invalid_types = @import_types - DataImport::IMPORT_TYPES + return if invalid_types.blank? + + raise ArgumentError, "Unsupported import types: #{invalid_types.join(', ')}" + end + + def client + @client ||= DataImports::Intercom::Client.new(access_token: @access_token) + end + + def total_count(response) + response['total_count'] if response.key?('total_count') + end +end diff --git a/app/services/data_imports/intercom/importer.rb b/app/services/data_imports/intercom/importer.rb new file mode 100644 index 000000000..d7d040d89 --- /dev/null +++ b/app/services/data_imports/intercom/importer.rb @@ -0,0 +1,991 @@ +# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Rails/SkipsModelValidations +class DataImports::Intercom::Importer + PageResult = Struct.new(:next_cursor, keyword_init: true) do + def done? + next_cursor.blank? + end + end + + DEFAULT_IMPORT_TYPES = %w[contacts conversations].freeze + PROVIDER = 'intercom'.freeze + ALREADY_IMPORTED_ERROR_CODE = 'DataImports::Intercom::AlreadyImported'.freeze + SKIPPED_MESSAGE_ERROR_CODE = 'DataImports::Intercom::SkippedMessage'.freeze + TRUNCATED_PARTS_ERROR_CODE = 'DataImports::Intercom::TruncatedConversationParts'.freeze + E164_REGEX = /\A\+[1-9]\d{1,14}\z/ + INTERCOM_NUMBER_REGEX = /\A[1-9]\d{1,14}\z/ + REGULAR_MESSAGE_PART_TYPES = %w[comment note source].freeze + + def initialize(data_import:, run_id: nil) + @data_import = data_import + @run_id = run_id + @account = data_import.account + @client = DataImports::Intercom::Client.new(access_token: data_import.access_token) + @placeholder_inboxes = DataImports::Intercom::PlaceholderInboxBuilder.new(account: @account) + @stats = default_stats.deep_merge(data_import.stats || {}) + end + + def perform + return unless start! + + import_contacts if import_type?('contacts') + import_conversations if import_type?('conversations') + finish! + rescue StandardError => e + fail!(e) + raise + end + + def start! + return if @data_import.reload.abandoned? + + @data_import.update!(status: :processing, started_at: @data_import.started_at || Time.current) + end + + def finish! + return if @data_import.reload.abandoned? + + has_failures = @data_import.import_errors.non_skip_logs.exists? || @data_import.import_errors.failed.exists? + status = has_failures ? :completed_with_errors : :completed + @data_import.update!( + status: status, + completed_at: Time.current, + stats: @stats, + total_records: total_processed_records, + processed_records: total_successful_records + ) + end + + def fail!(error) + return if @data_import.reload.abandoned? + + record_run_error(error) + @data_import.update!(status: :failed, last_error_at: Time.current) + end + + def import_contacts_page(starting_after: cursor_for('contacts')) + response = @client.list_contacts(starting_after: starting_after) + update_stat_total('contacts', response['total_count']) if response['total_count'].present? + Array(response['data'] || response['contacts']).each do |contact| + break if import_stopped? + + import_contact(contact) + end + return PageResult.new(next_cursor: nil) if import_stopped? + + next_cursor = response.dig('pages', 'next', 'starting_after') + update_cursor('contacts', next_cursor) + PageResult.new(next_cursor: next_cursor) + end + + def import_conversations_page(starting_after: cursor_for('conversations')) + response = @client.list_conversations(starting_after: starting_after) + update_stat_total('conversations', response['total_count']) if response['total_count'].present? + Array(response['data'] || response['conversations']).each do |conversation_summary| + break if import_stopped? + + import_conversation_from_summary(conversation_summary) + end + return PageResult.new(next_cursor: nil) if import_stopped? + + next_cursor = response.dig('pages', 'next', 'starting_after') + update_cursor('conversations', next_cursor) + PageResult.new(next_cursor: next_cursor) + end + + def import_contacts? + import_type?('contacts') + end + + def import_conversations? + import_type?('conversations') + end + + def contacts_completed? + stage_completed?('contacts') + end + + def conversations_completed? + stage_completed?('conversations') + end + + def cursor_for(key) + @data_import.cursor&.dig(key, 'starting_after') + end + + private + + def import_contacts + cursor = cursor_for('contacts') + loop do + result = import_contacts_page(starting_after: cursor) + break if result.done? + + cursor = result.next_cursor + end + end + + def import_conversations + cursor = cursor_for('conversations') + loop do + result = import_conversations_page(starting_after: cursor) + break if result.done? + + cursor = result.next_cursor + end + end + + def import_conversation_from_summary(conversation_summary) + source_id = source_id_for(conversation_summary) + already_handled = item_handled?('conversation', source_id) + item = import_item('conversation', source_id, conversation_summary) + mapping = find_mapping('conversation', source_id) + + conversation = @client.retrieve_conversation(source_id) + return if import_stopped? + + update_message_total(item, conversation) + + contact = import_contact(primary_conversation_contact(conversation), required_for_conversation: true) + source_type = conversation_source_type(conversation, conversation_summary) + inbox = @placeholder_inboxes.inbox_for(source_type) + contact_inbox = contact_inbox_for(contact, inbox) + + mapped_conversation = mapping&.chatwoot_record + if mapped_conversation && mapping.data_import_id != @data_import.id + skip_already_imported_item(item, mapping, already_handled: already_handled) + import_source_message(conversation, mapped_conversation, contact) + import_conversation_parts(conversation, mapped_conversation, contact) + update_conversation_activity(mapped_conversation) + return + end + + chatwoot_conversation = mapped_conversation || create_conversation(conversation, contact, contact_inbox, inbox, source_type) + if mapped_conversation + record_mapping('conversation', source_id, chatwoot_conversation, metadata: conversation_metadata(conversation, inbox, source_type)) + end + item.update!(status: :imported, chatwoot_record_type: 'Conversation', chatwoot_record_id: chatwoot_conversation.id) + increment_stat('conversations', 'imported') unless already_handled + + import_source_message(conversation, chatwoot_conversation, contact) + import_conversation_parts(conversation, chatwoot_conversation, contact) + update_conversation_activity(chatwoot_conversation) + rescue StandardError => e + raise if e.is_a?(DataImports::Intercom::Client::Error) + + fail_item(item, e) + ensure + persist_stats + end + + def import_stopped? + return true if @import_stopped + + @data_import.reload + @import_stopped = @data_import.abandoned? || @data_import.completed? || @data_import.completed_with_errors? || stale_import_run? + end + + def stale_import_run? + active_run_id = @data_import.active_intercom_import_run_id + @run_id.present? && active_run_id.present? && active_run_id != @run_id + end + + def import_contact(contact_payload, required_for_conversation: false) + source_id = source_id_for(contact_payload) + if source_id.present? && (mapping = find_mapping('contact', source_id)) && (mapped_contact = mapping.chatwoot_record) + return reuse_mapped_contact(contact_payload, source_id, mapping, mapped_contact) + end + + contact_payload = retrieve_contact_payload(contact_payload) + source_id = source_id_for(contact_payload) + already_handled = item_handled?('contact', source_id) + item = import_item('contact', source_id, contact_payload) + mapping = find_mapping('contact', source_id) + + mapped_contact = mapping&.chatwoot_record + if mapped_contact && mapping.data_import_id != @data_import.id + skip_already_imported_item(item, mapping, already_handled: already_handled) + return mapped_contact + end + + contact = Contact.transaction do + imported_contact = mapped_contact || find_existing_contact(contact_payload) || create_contact(contact_payload) + update_existing_contact(imported_contact, contact_payload) + record_mapping('contact', source_id, imported_contact, metadata: contact_metadata(contact_payload)) + item.update!(status: :imported, chatwoot_record_type: 'Contact', chatwoot_record_id: imported_contact.id) + imported_contact + end + increment_stat('contacts', 'imported') unless already_handled + contact + rescue StandardError => e + raise if e.is_a?(DataImports::Intercom::Client::Error) + + fail_item(item, e) + raise if required_for_conversation + ensure + persist_stats + end + + def retrieve_contact_payload(contact_payload) + return contact_payload if contact_payload.blank? + return contact_payload if contact_payload['email'].present? || contact_payload['phone'].present? || contact_payload['name'].present? + return contact_payload if contact_payload['id'].blank? + + @client.retrieve_contact(contact_payload['id']) + rescue DataImports::Intercom::Client::Error => e + raise unless e.status == 404 + + contact_payload + end + + def create_contact(contact_payload) + attrs = contact_attributes(contact_payload).merge(created_at: timestamp_for(contact_payload['created_at']), updated_at: Time.current) + result = Contact.insert_all!([attrs], returning: %w[id]) + Contact.find(result.rows.first.first) + rescue ActiveRecord::RecordNotUnique + find_existing_contact(contact_payload) + end + + def reuse_mapped_contact(contact_payload, source_id, mapping, mapped_contact) + if mapping.data_import_id == @data_import.id + reconcile_current_run_contact(contact_payload, source_id, mapped_contact) + return mapped_contact + end + + already_handled = item_handled?('contact', source_id) + item = import_item('contact', source_id, contact_payload) + skip_already_imported_item(item, mapping, already_handled: already_handled) + mapped_contact + end + + def update_existing_contact(contact, contact_payload) + attrs = contact_attributes(contact_payload) + updates = {} + updates[:name] = attrs[:name] if contact.name.blank? && attrs[:name].present? + updates[:email] = attrs[:email] if contact_email_available?(contact, attrs[:email]) + updates[:phone_number] = attrs[:phone_number] if contact_phone_number_available?(contact, attrs[:phone_number]) + updates[:identifier] = attrs[:identifier] if contact.identifier.blank? && attrs[:identifier].present? + updates[:last_activity_at] = attrs[:last_activity_at] if contact.last_activity_at.blank? && attrs[:last_activity_at].present? + updates[:additional_attributes] = contact.additional_attributes.to_h.deep_merge(attrs[:additional_attributes]) + updates[:custom_attributes] = contact.custom_attributes.to_h.deep_merge(attrs[:custom_attributes]) + if contact.visitor? && attrs[:contact_type].present? && contact_resolved_after_update?(contact, updates) + updates[:contact_type] = attrs[:contact_type] + end + updates[:updated_at] = Time.current + contact.update_columns(updates) if updates.present? + contact.reload + end + + def contact_email_available?(contact, email) + return false if contact.email.present? || email.blank? + + @account.contacts.where.not(id: contact.id).where('LOWER(email) = ?', email.downcase).empty? + end + + def contact_phone_number_available?(contact, phone_number) + return false if contact.phone_number.present? || phone_number.blank? + + @account.contacts.where.not(id: contact.id).where(phone_number: phone_number).empty? + end + + def contact_resolved_after_update?(contact, updates) + contact.email.present? || contact.phone_number.present? || updates[:email].present? || updates[:phone_number].present? + end + + def find_existing_contact(contact_payload) + identifier = normalized_identifier(contact_payload) + email = normalized_email(contact_payload) + phone_number = normalized_phone(contact_payload) + + if identifier.present? + contact = @account.contacts.find_by(identifier: identifier) + return contact if contact.present? + end + + if email.present? + contact = @account.contacts.from_email(email) + return contact if contact.present? + end + return @account.contacts.find_by(phone_number: phone_number) if phone_number.present? + + nil + end + + def contact_attributes(contact_payload) + attrs = { + account_id: @account.id, + name: contact_payload['name'].presence || contact_payload['email'].presence || '', + email: normalized_email(contact_payload), + phone_number: normalized_phone(contact_payload), + identifier: normalized_identifier(contact_payload), + last_activity_at: contact_activity_at(contact_payload), + additional_attributes: { + source: { + provider: PROVIDER, + contact_id: contact_payload['id'], + external_id: contact_payload['external_id'], + raw_phone: contact_payload['phone'] + }.compact + }, + custom_attributes: { + intercom_contact_id: contact_payload['id'], + intercom_external_id: contact_payload['external_id'] + }.compact + } + attrs[:contact_type] = Contact.contact_types[:lead] if attrs[:email].present? || attrs[:phone_number].present? + attrs + end + + def create_conversation(conversation, contact, contact_inbox, inbox, source_type) + source_id = source_id_for(conversation) + metadata = conversation_metadata(conversation, inbox, source_type) + if (existing_conversation = @account.conversations.find_by(identifier: conversation_identifier(conversation))) + record_mapping('conversation', source_id, existing_conversation, metadata: metadata) + return existing_conversation + end + + attrs = { + account_id: @account.id, + inbox_id: inbox.id, + status: Conversation.statuses['resolved'], + contact_id: contact.id, + contact_inbox_id: contact_inbox.id, + identifier: conversation_identifier(conversation), + additional_attributes: metadata, + custom_attributes: { intercom_conversation_id: source_id }, + created_at: timestamp_for(conversation['created_at']), + updated_at: timestamp_for(conversation['updated_at']), + last_activity_at: timestamp_for(conversation['updated_at']) + } + + Conversation.transaction do + result = Conversation.insert_all!([attrs], returning: %w[id]) + chatwoot_conversation = Conversation.find(result.rows.first.first) + record_mapping('conversation', source_id, chatwoot_conversation, metadata: metadata) + chatwoot_conversation + end + rescue ActiveRecord::RecordNotUnique + @account.conversations.find_by!(identifier: conversation_identifier(conversation)).tap do |chatwoot_conversation| + record_mapping('conversation', source_id, chatwoot_conversation, metadata: metadata) + end + end + + def import_source_message(conversation, chatwoot_conversation, contact) + source = conversation['source'].to_h + return unless source_message_importable?(source) + + message_source_id = "conversation:#{source_id_for(conversation)}:source:#{source['id'].presence || 'initial'}" + source_part = source.merge('part_type' => 'source', 'created_at' => conversation['created_at']) + if (mapping = find_mapping('message', message_source_id)) && message_mapping_handled?(mapping, source_part) + if mapping.data_import_id == @data_import.id + reconcile_current_run_message_mapping(chatwoot_conversation, mapping, source_part) + return + end + + skip_existing_message_mapping(chatwoot_conversation, mapping, source_part) + return + end + + create_message(chatwoot_conversation, contact, source_part, message_source_id) + rescue StandardError => e + fail_message(chatwoot_conversation, message_source_id, source_part, e) + end + + def import_conversation_parts(conversation, chatwoot_conversation, contact) + parts_payload = conversation['conversation_parts'].to_h + parts = Array(parts_payload['conversation_parts']) + record_truncated_conversation_parts(conversation, parts.size) + + parts.each do |part| + message_source_id = "conversation:#{source_id_for(conversation)}:part:#{part['id']}" + if (mapping = find_mapping('message', message_source_id)) && message_mapping_handled?(mapping, part) + if mapping.data_import_id == @data_import.id + reconcile_current_run_message_mapping(chatwoot_conversation, mapping, part) + next + end + + skip_existing_message_mapping(chatwoot_conversation, mapping, part) + next + end + + create_message(chatwoot_conversation, contact, part, message_source_id) + rescue StandardError => e + fail_message(chatwoot_conversation, message_source_id, part, e) + end + end + + def create_message(conversation, contact, part, message_source_id) + content = content_for(part) + return record_skipped_message(conversation, message_source_id, part) if content.blank? + + attrs = message_attributes(conversation, contact, part, message_source_id, content) + message = nil + Message.transaction do + message = conversation.messages.find_by(source_id: attrs[:source_id]) + unless message + result = Message.insert_all!([attrs], returning: %w[id]) + message = Message.find(result.rows.first.first) + end + record_mapping('message', message_source_id, message, metadata: message_metadata(part)) + end + increment_stat('messages', 'imported') + reindex_message_for_search(message) + message + end + + def reindex_message_for_search(message) + return unless message.should_index? + + message.__send__(:reindex_for_search) + rescue StandardError => e + Rails.logger.warn("Intercom import message reindex failed for message #{message.id}: #{e.class} - #{e.message}") + end + + def record_skipped_message(conversation, message_source_id, part) + mapping = find_mapping('message', message_source_id) + if mapping + already_recorded = skip_log_recorded?('message', message_source_id, SKIPPED_MESSAGE_ERROR_CODE) + record_skipped_message_log(conversation, message_source_id, part) + increment_stat('messages', 'skipped') unless already_recorded + return mapping.chatwoot_record + end + + DataImportMapping.create!( + account: @account, + data_import: @data_import, + source_provider: PROVIDER, + source_object_type: 'message', + source_object_id: message_source_id, + chatwoot_record_type: 'Conversation', + chatwoot_record_id: conversation.id, + metadata: message_metadata(part).merge(skipped: true, reason: 'blank_or_unsupported_intercom_part') + ) + record_skipped_message_log(conversation, message_source_id, part) + increment_stat('messages', 'skipped') + end + + def message_attributes(conversation, contact, part, message_source_id, content) + message_type = message_type_for(part) + created_at = timestamp_for(part['created_at']) + { + account_id: @account.id, + inbox_id: conversation.inbox_id, + conversation_id: conversation.id, + message_type: Message.message_types[message_type], + content_type: Message.content_types['text'], + content: content, + processed_message_content: content, + private: message_type != 'activity' && part['part_type'] == 'note', + status: Message.statuses['sent'], + sender_type: message_type == 'incoming' ? 'Contact' : nil, + sender_id: message_type == 'incoming' ? contact.id : nil, + source_id: "intercom:#{message_source_id}", + external_source_ids: { intercom: message_source_id }, + content_attributes: {}, + additional_attributes: message_metadata(part), + created_at: created_at, + updated_at: part['updated_at'].present? ? timestamp_for(part['updated_at']) : created_at + } + end + + def message_type_for(part) + return 'activity' if activity_part?(part) + + author_type = part.dig('author', 'type').to_s + return 'incoming' if %w[user contact lead].include?(author_type) + + 'outgoing' + end + + def content_for(part) + return DataImports::Intercom::ActivityContentBuilder.new(part).perform if activity_part?(part) + + message_content(part) + end + + def activity_part?(part) + part_type = part['part_type'].to_s + part_type.present? && REGULAR_MESSAGE_PART_TYPES.exclude?(part_type) + end + + def message_content(part) + body = sanitized_text(part['body']) + subject = sanitized_text(part['subject']) + attachments = Array(part['attachments']) + content = [subject, body].reject(&:blank?).join("\n\n") + return content if attachments.blank? + + [content.presence, "[Intercom attachment skipped: #{attachments.size}]"].compact.join("\n\n") + end + + def sanitized_text(value) + Rails::HTML5::FullSanitizer.new.sanitize(value.to_s).squish + end + + def update_conversation_activity(conversation) + latest_message = conversation.messages.reorder(created_at: :desc).first + return if latest_message.blank? + + conversation.update_columns(last_activity_at: latest_message.created_at, updated_at: Time.current) + end + + def contact_inbox_for(contact, inbox) + ContactInbox.find_or_create_by!(contact: contact, inbox: inbox) do |contact_inbox| + contact_inbox.source_id = "intercom:#{contact.id}" + end + end + + def primary_conversation_contact(conversation) + contacts = conversation.dig('contacts', 'contacts') || [] + contacts.first || conversation.dig('source', 'author') || {} + end + + def conversation_source_type(conversation, conversation_summary) + conversation.dig('source', 'type').presence || + conversation.dig('first_contact_reply', 'type').presence || + conversation_summary.dig('source', 'type').presence || + conversation_summary.dig('first_contact_reply', 'type').presence + end + + def normalized_identifier(contact_payload) + contact_payload['external_id'].presence + end + + def normalized_email(contact_payload) + email = contact_payload['email'].to_s.strip.downcase + email.match?(Devise.email_regexp) ? email : nil + end + + def normalized_phone(contact_payload) + phone = contact_payload['phone'].to_s.strip + phone = "+#{phone}" if phone.match?(INTERCOM_NUMBER_REGEX) + phone.match?(E164_REGEX) ? phone : nil + end + + def contact_activity_at(contact_payload) + return timestamp_for(contact_payload['last_seen_at']) if contact_payload['last_seen_at'].present? + return timestamp_for(contact_payload['last_replied_at']) if contact_payload['last_replied_at'].present? + + nil + end + + def source_id_for(payload) + payload['id'].presence || payload['external_id'].presence || payload['email'].presence + end + + def conversation_identifier(conversation) + "intercom:#{source_id_for(conversation)}" + end + + def import_item(object_type, source_id, metadata) + @data_import.items.find_or_initialize_by( + source_provider: PROVIDER, + source_object_type: object_type, + source_object_id: source_id + ).tap do |item| + item.status = :processing + item.attempt_count += 1 + item.metadata = item.metadata.to_h.merge(metadata.to_h) + item.save! + end + end + + def item_handled?(object_type, source_id) + @data_import.items.where(status: [:imported, :skipped]).exists?( + source_provider: PROVIDER, + source_object_type: object_type, + source_object_id: source_id + ) + end + + def find_mapping(object_type, source_id) + DataImportMapping.find_by( + account: @account, + source_provider: PROVIDER, + source_object_type: object_type, + source_object_id: source_id + ) + end + + def record_mapping(object_type, source_id, record, metadata: {}) + DataImportMapping.find_or_initialize_by( + account: @account, + source_provider: PROVIDER, + source_object_type: object_type, + source_object_id: source_id + ).tap do |mapping| + mapping.data_import = @data_import + mapping.chatwoot_record_type = record.class.name + mapping.chatwoot_record_id = record.id + mapping.metadata = metadata + mapping.save! + end + end + + def reconcile_current_run_contact(contact_payload, source_id, mapped_contact) + item = @data_import.items.find_by( + source_provider: PROVIDER, + source_object_type: 'contact', + source_object_id: source_id + ) + item = import_item('contact', source_id, contact_payload) unless item&.imported? + item.update!(status: :imported, chatwoot_record_type: 'Contact', chatwoot_record_id: mapped_contact.id) + reconcile_item_stats('contact') + end + + def reconcile_item_stats(source_object_type) + items = @data_import.items.where(source_provider: PROVIDER, source_object_type: source_object_type) + group = stat_group_for(source_object_type) + @stats[group]['imported'] = items.imported.count + @stats[group]['skipped'] = items.skipped.count + persist_stats + end + + def reconcile_current_run_message_mapping(conversation, mapping, part) + record_skipped_message_log(conversation, mapping.source_object_id, part) if mapping.metadata['skipped'] + + mappings = @data_import.mappings.where(source_provider: PROVIDER, source_object_type: 'message') + skipped_mappings = mappings.where("metadata ->> 'skipped' = ?", 'true').count + message_logs = @data_import.import_errors.where(source_object_type: 'message') + @stats['messages']['imported'] = mappings.count - skipped_mappings + @stats['messages']['skipped'] = message_logs.where("details ->> 'kind' = ?", 'skipped').count + persist_stats + end + + def skip_already_imported_item(item, mapping, already_handled:) + item.update!( + status: :skipped, + chatwoot_record_type: mapping.chatwoot_record_type, + chatwoot_record_id: mapping.chatwoot_record_id, + last_error_code: ALREADY_IMPORTED_ERROR_CODE, + last_error_message: 'Already imported in a previous import.' + ) + record_already_imported_log( + data_import_item: item, + source_object_type: item.source_object_type, + source_object_id: item.source_object_id, + mapping: mapping + ) + increment_stat(stat_group_for(item.source_object_type), 'skipped') unless already_handled + end + + def skip_existing_message_mapping(conversation, mapping, part) + if mapping.metadata['skipped'] + already_recorded = skip_log_recorded?('message', mapping.source_object_id, SKIPPED_MESSAGE_ERROR_CODE) + record_skipped_message_log(conversation, mapping.source_object_id, part) + else + already_recorded = skip_log_recorded?('message', mapping.source_object_id, ALREADY_IMPORTED_ERROR_CODE) + record_already_imported_log(source_object_type: 'message', source_object_id: mapping.source_object_id, mapping: mapping) + end + increment_stat('messages', 'skipped') unless already_recorded + end + + def message_mapping_handled?(mapping, part) + return false if mapping.metadata['skipped'] && activity_part?(part) + + mapping.metadata['skipped'] || mapping.chatwoot_record.present? + end + + def fail_item(item, error) + increment_stat('errors', 'count') + item&.update!(status: :failed, last_error_code: error.class.name, last_error_message: error.message) + record_skip_log( + data_import_item: item, + source_object_type: item&.source_object_type, + source_object_id: item&.source_object_id, + error_code: error.class.name, + message: error.message, + details: { + kind: 'failed', + source_provider: PROVIDER, + error_class: error.class.name + } + ) + end + + def fail_message(conversation, message_source_id, part, error) + increment_stat('errors', 'count') + record_skip_log( + source_object_type: 'message', + source_object_id: message_source_id, + error_code: error.class.name, + message: error.message, + details: message_metadata(part).merge( + kind: 'failed', + source_provider: PROVIDER, + error_class: error.class.name, + conversation_id: conversation.identifier + ) + ) + end + + def record_skipped_message_log(conversation, message_source_id, part) + record_skip_log( + source_object_type: 'message', + source_object_id: message_source_id, + error_code: SKIPPED_MESSAGE_ERROR_CODE, + message: skipped_message_log_message(part), + details: skipped_message_details(conversation, part) + ) + end + + def record_already_imported_log(source_object_type:, source_object_id:, mapping:, data_import_item: nil) + record_skip_log( + data_import_item: data_import_item, + source_object_type: source_object_type, + source_object_id: source_object_id, + error_code: ALREADY_IMPORTED_ERROR_CODE, + message: 'Already imported in a previous import.', + details: { + kind: 'skipped', + reason: 'already_imported', + source_provider: PROVIDER, + previous_data_import_id: mapping.data_import_id, + chatwoot_record_type: mapping.chatwoot_record_type, + chatwoot_record_id: mapping.chatwoot_record_id + } + ) + end + + def record_truncated_conversation_parts(conversation, imported_parts_count) + total_parts_count = total_conversation_parts_count(conversation) + return if total_parts_count <= imported_parts_count + + source_id = source_id_for(conversation) + already_recorded = @data_import.import_errors.exists?( + source_object_type: 'conversation', + source_object_id: source_id, + error_code: TRUNCATED_PARTS_ERROR_CODE + ) + record_import_error( + source_object_type: 'conversation', + source_object_id: source_id, + error_code: TRUNCATED_PARTS_ERROR_CODE, + message: "Intercom returned #{imported_parts_count} of #{total_parts_count} conversation parts.", + details: { + kind: 'incomplete', + source_provider: PROVIDER, + imported_parts_count: imported_parts_count, + total_parts_count: total_parts_count + } + ) + increment_stat('errors', 'count') unless already_recorded + end + + def total_conversation_parts_count(conversation) + conversation_parts_total_count = conversation.dig('conversation_parts', 'total_count') + return conversation_parts_total_count.to_i if conversation_parts_total_count.present? + + [ + conversation.dig('statistics', 'count_conversation_parts'), + conversation.dig('statistics', 'count_conversations_parts') + ].compact.map(&:to_i).max || 0 + end + + def source_message_importable?(source) + source['body'].present? || source['subject'].present? || source['attachments'].present? + end + + def skipped_message_log_message(part) + "Skipped Intercom #{intercom_event_name(part)} event#{intercom_part_id_suffix(part)}: #{skipped_message_reason_details(part)}." + end + + def skipped_message_details(conversation, part) + author = part['author'].to_h + message_metadata(part).merge( + { + kind: 'skipped', + reason: 'blank_or_unsupported_intercom_part', + reason_details: skipped_message_reason_details(part), + event_name: intercom_event_name(part), + event_type: part['part_type'], + author_type: author['type'], + author_name: author['name'], + conversation_id: conversation.identifier + }.compact + ) + end + + def skipped_message_reason_details(part) + return 'message body did not contain readable text after HTML sanitization' if part['body'].present? + return 'attachments are present but no importable message text was found' if Array(part['attachments']).present? + + 'no message body or attachments to import' + end + + def intercom_event_name(part) + part['part_type'].to_s.tr('_', ' ').presence || 'message part' + end + + def intercom_part_id_suffix(part) + part['id'].present? ? " #{part['id']}" : '' + end + + def skip_log_recorded?(source_object_type, source_object_id, error_code) + @data_import.import_errors.skip_logs.exists?( + source_object_type: source_object_type, + source_object_id: source_object_id, + error_code: error_code + ) + end + + def record_run_error(error) + @data_import.import_errors.create!( + error_code: error.class.name, + message: error.message, + details: { + kind: 'run_error', + source_provider: PROVIDER, + error_class: error.class.name + } + ) + end + + def record_skip_log(attributes) + record_import_error(attributes) + end + + def record_import_error(attributes) + @data_import.import_errors.find_or_initialize_by( + data_import_item: attributes[:data_import_item], + source_object_type: attributes[:source_object_type], + source_object_id: attributes[:source_object_id], + error_code: attributes[:error_code] + ).tap do |import_error| + import_error.message = attributes[:message] + import_error.details = attributes[:details] + import_error.save! + end + end + + def conversation_metadata(conversation, inbox, source_type) + { + source: { + provider: PROVIDER, + conversation_id: source_id_for(conversation), + source_type: source_type, + delivered_as: conversation.dig('source', 'delivered_as'), + source_url: conversation.dig('source', 'url'), + admin_assignee_id: conversation['admin_assignee_id'], + team_assignee_id: conversation['team_assignee_id'], + state: conversation['state'], + open: conversation['open'], + routing_method: 'source_bucket_api_inbox', + routed_inbox_id: inbox.id, + import_id: @data_import.id + }.compact + } + end + + def contact_metadata(contact_payload) + { + source: { + provider: PROVIDER, + contact_id: contact_payload['id'], + external_id: contact_payload['external_id'] + }.compact + } + end + + def message_metadata(part) + { + source: { + provider: PROVIDER, + part_id: part['id'], + part_type: part['part_type'], + author: part['author'], + assigned_to: part['assigned_to'], + state: part['state'], + tags: part['tags'], + event_details: part['event_details'], + app_package_code: part['app_package_code'], + metadata: part['metadata'], + attachments: part['attachments'], + redacted: part['redacted'] + }.compact + } + end + + def timestamp_for(value) + return Time.current if value.blank? + + Time.zone.at(value.to_i) + end + + def update_cursor(key, cursor) + @data_import.cursor = @data_import.cursor.to_h.merge( + key => { starting_after: cursor, completed: cursor.blank?, updated_at: Time.current.iso8601 } + ) + @data_import.save! + end + + def stage_completed?(key) + @data_import.cursor&.dig(key, 'completed') == true + end + + def import_type?(type) + import_types.include?(type) + end + + def import_types + @import_types ||= (@data_import.import_types.presence || DEFAULT_IMPORT_TYPES) + end + + def increment_stat(group, key) + @stats[group] ||= {} + @stats[group][key] = @stats[group][key].to_i + 1 + end + + def update_stat_total(group, total) + @stats[group] ||= {} + @stats[group]['total'] = total.to_i + persist_stats + end + + def update_message_total(item, conversation) + parts = conversation['conversation_parts'].to_h + conversation_parts_total = if parts.key?('total_count') + parts['total_count'].to_i + else + Array(parts['conversation_parts']).size + end + contribution = conversation_parts_total + contribution += 1 if source_message_importable?(conversation['source'].to_h) + previous_contribution = item.metadata.to_h['message_total_contribution'].to_i + + @stats['messages']['total'] = @stats['messages']['total'].to_i + contribution - previous_contribution + item.update!(metadata: item.metadata.to_h.merge('message_total_contribution' => contribution)) + persist_stats + end + + def stat_group_for(source_object_type) + "#{source_object_type}s" + end + + def persist_stats + @data_import.update_columns(stats: @stats, updated_at: Time.current) + end + + def default_stats + { + 'contacts' => { 'imported' => 0, 'skipped' => 0 }, + 'conversations' => { 'imported' => 0, 'skipped' => 0 }, + 'messages' => { 'imported' => 0, 'skipped' => 0 }, + 'errors' => { 'count' => 0 } + } + end + + def total_processed_records + total_successful_records + + @stats.fetch('contacts', {}).fetch('skipped', 0).to_i + + @stats.fetch('conversations', {}).fetch('skipped', 0).to_i + + @stats.fetch('messages', {}).fetch('skipped', 0).to_i + + @stats.fetch('errors', {}).fetch('count', 0).to_i + end + + def total_successful_records + @stats.fetch('contacts', {}).fetch('imported', 0).to_i + + @stats.fetch('conversations', {}).fetch('imported', 0).to_i + + @stats.fetch('messages', {}).fetch('imported', 0).to_i + end +end +# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Rails/SkipsModelValidations diff --git a/app/services/data_imports/intercom/placeholder_inbox_builder.rb b/app/services/data_imports/intercom/placeholder_inbox_builder.rb new file mode 100644 index 000000000..c46a1f439 --- /dev/null +++ b/app/services/data_imports/intercom/placeholder_inbox_builder.rb @@ -0,0 +1,41 @@ +class DataImports::Intercom::PlaceholderInboxBuilder + AGENT_REPLY_TIME_WINDOW_HOURS = 1 + + def initialize(account:) + @account = account + end + + def inbox_for(source_type) + bucket = DataImports::Intercom::SourceBucket.for(source_type) + placeholder_inboxes[bucket[:key]] ||= create_placeholder_inbox(bucket) + end + + private + + def placeholder_inboxes + @placeholder_inboxes ||= @account.inboxes.includes(:channel).where(channel_type: 'Channel::Api').each_with_object({}) do |inbox, inboxes| + attrs = inbox.channel.additional_attributes || {} + next unless attrs['source_provider'] == 'intercom' && attrs['import_placeholder'] == true + + inboxes[attrs['source_bucket']] = inbox + end + end + + def create_placeholder_inbox(bucket) + channel = @account.api_channels.create!( + additional_attributes: { + source_provider: 'intercom', + source_bucket: bucket[:key], + import_placeholder: true, + agent_reply_time_window: AGENT_REPLY_TIME_WINDOW_HOURS + } + ) + + @account.inboxes.create!( + name: "Intercom Import - #{bucket[:name]}", + channel: channel, + enable_auto_assignment: false, + allow_messages_after_resolved: false + ) + end +end diff --git a/app/services/data_imports/intercom/restart_service.rb b/app/services/data_imports/intercom/restart_service.rb new file mode 100644 index 000000000..6fd55c633 --- /dev/null +++ b/app/services/data_imports/intercom/restart_service.rb @@ -0,0 +1,55 @@ +class DataImports::Intercom::RestartService + attr_reader :data_import + + def initialize(account:, data_import:) + @account = account + @data_import = data_import + end + + def perform + @account.with_lock do + @data_import.reload + next :render_show unless @data_import.restartable? + + if (active_import = find_active_import) + @data_import = active_import + next :render_show + end + + next :access_token_missing if @data_import.access_token.blank? + + @data_import.assign_active_intercom_import_run_id + retained_skip_logs = @data_import.import_errors.where("details ->> 'kind' = ?", 'skipped') + @data_import.import_errors.where.not(id: retained_skip_logs.select(:id)).delete_all + @data_import.update!(restart_attributes(retained_skip_logs)) + :enqueue + end + end + + private + + def find_active_import + @account.data_imports.active_intercom.first + end + + def restart_attributes(retained_skip_logs) + { + status: :pending, + abandoned_at: nil, + completed_at: nil, + last_error_at: nil, + started_at: nil, + stats: restart_stats(retained_skip_logs) + } + end + + def restart_stats(retained_skip_logs) + @data_import.stats.to_h.deep_dup.tap do |stats| + %w[contact conversation message].each do |object_type| + stats["#{object_type}s"] ||= {} + stats["#{object_type}s"]['skipped'] = retained_skip_logs.where(source_object_type: object_type).count + end + stats['errors'] = { 'count' => 0 } + end + end +end diff --git a/app/services/data_imports/intercom/source_bucket.rb b/app/services/data_imports/intercom/source_bucket.rb new file mode 100644 index 000000000..6d66f71dc --- /dev/null +++ b/app/services/data_imports/intercom/source_bucket.rb @@ -0,0 +1,23 @@ +class DataImports::Intercom::SourceBucket + BUCKETS = { + 'email' => { key: 'email', name: 'Email' }, + 'instagram' => { key: 'instagram', name: 'Instagram' }, + 'facebook' => { key: 'facebook', name: 'Facebook' }, + 'sms' => { key: 'sms', name: 'SMS' }, + 'twitter' => { key: 'twitter', name: 'Twitter' }, + 'whatsapp' => { key: 'whatsapp', name: 'WhatsApp' }, + 'phone' => { key: 'phone', name: 'Phone' }, + 'phone_call' => { key: 'phone', name: 'Phone' }, + 'phone_switch' => { key: 'phone', name: 'Phone' }, + 'inapp' => { key: 'messenger', name: 'Messenger' }, + 'messenger' => { key: 'messenger', name: 'Messenger' }, + 'conversation' => { key: 'messenger', name: 'Messenger' }, + 'push' => { key: 'messenger', name: 'Messenger' } + }.freeze + + DEFAULT_BUCKET = { key: 'unknown', name: 'Unknown' }.freeze + + def self.for(source_type) + BUCKETS[source_type.to_s.downcase] || DEFAULT_BUCKET + end +end diff --git a/app/services/email_templates/db_resolver_service.rb b/app/services/email_templates/db_resolver_service.rb index 42222fe75..0a4621aa9 100644 --- a/app/services/email_templates/db_resolver_service.rb +++ b/app/services/email_templates/db_resolver_service.rb @@ -29,12 +29,14 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver end # rubocop:enable Metrics/ParameterLists - # the function has to accept(name, prefix, partial, _details, _locals = []) - # _details contain local info which we can leverage in future + # the function has to accept(name, prefix, partial, details, locals = []) + # details contain local info which we can leverage in future # cause of codeclimate issue with 4 args, relying on (*args) - def find_templates(name, prefix, partial, *_args) + def find_templates(name, prefix, partial, *args) @template_name = name - @template_type = prefix.include?('layout') ? 'layout' : 'content' + @template_type = prefix.to_s.include?('layout') ? 'layout' : 'content' + @prefix = prefix + @details = args.first if args.first.is_a?(Hash) @db_template = find_db_template return [] if @db_template.blank? @@ -54,17 +56,62 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver private def find_db_template - find_account_template || find_installation_template + find_inbox_template || find_account_template || find_installation_template + end + + def find_inbox_template + return unless email_inbox_layout_lookup? && branded_email_templates_enabled? + + find_template_for(@@model.where(inbox: Current.inbox)) end def find_account_template return unless Current.account + return if account_layout_lookup? && !branded_email_templates_enabled? - @@model.find_by(name: @template_name, template_type: @template_type, account: Current.account) + find_template_for(@@model.where(account: Current.account, inbox: nil)) end def find_installation_template - @@model.find_by(name: @template_name, template_type: @template_type, account: nil) + find_template_for(@@model.where(account: nil, inbox: nil)) + end + + def account_layout_lookup? + @template_type == 'layout' && Current.account.present? + end + + def email_inbox_layout_lookup? + account_layout_lookup? && Current.inbox&.email? + end + + def branded_email_templates_enabled? + Current.account&.feature_enabled?(:branded_email_templates) + end + + def find_template_for(relation) + locale_candidates.each do |locale| + template_names.each do |name| + template = relation.find_by(name: name, template_type: @template_type, locale: locale) + return template if template.present? + end + end + + nil + end + + def locale_candidates + locale = Array(@details&.dig(:locale)).first + EmailTemplate.locale_candidates(locale.presence || EmailTemplate::DEFAULT_LOCALE) + end + + def template_names + [db_template_name, @template_name].uniq + end + + def db_template_name + return @template_name if @template_type == 'layout' + + build_path(@prefix) end # Build path with eventual prefix diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb index e55355f3a..9c5b8e27b 100644 --- a/app/services/imap/base_fetch_email_service.rb +++ b/app/services/imap/base_fetch_email_service.rb @@ -38,7 +38,8 @@ class Imap::BaseFetchEmailService end def email_already_present?(channel, message_id) - channel.inbox.messages.find_by(source_id: message_id).present? || deleted_message_tracker.deleted?(message_id) + # exists? avoids Message's default_scope ORDER BY, which full-scans large inboxes + channel.inbox.messages.exists?(source_id: message_id) || deleted_message_tracker.deleted?(message_id) end def deleted_message_tracker diff --git a/app/services/labels/destroy_service.rb b/app/services/labels/destroy_service.rb index 080e708e7..b5d7add33 100644 --- a/app/services/labels/destroy_service.rb +++ b/app/services/labels/destroy_service.rb @@ -2,19 +2,25 @@ class Labels::DestroyService pattr_initialize [:label_title!, :account_id!, :label_deleted_at!] def perform - remove_conversation_labels + conversation_labels_removed = remove_conversation_labels remove_contact_labels + invalidate_filtered_unread_count_conversations if conversation_labels_removed end private def remove_conversation_labels + conversation_labels_removed = false + tagged_conversations.find_in_batches do |conversation_batch| conversation_batch.each do |conversation| update_conversation_cached_labels(conversation) end delete_label_taggings('Conversation', conversation_batch.map(&:id)) + conversation_labels_removed = true end + + conversation_labels_removed end def remove_contact_labels @@ -57,4 +63,8 @@ class Labels::DestroyService def account @account ||= Account.find(account_id) end + + def invalidate_filtered_unread_count_conversations + ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).conversation_changed! + end end diff --git a/app/services/reports/drilldown_timestamp_validator.rb b/app/services/reports/drilldown_timestamp_validator.rb new file mode 100644 index 000000000..d6371c2d9 --- /dev/null +++ b/app/services/reports/drilldown_timestamp_validator.rb @@ -0,0 +1,53 @@ +module Reports::DrilldownTimestampValidator + extend TimezoneHelper + + TIMESTAMP_PARAMS = %i[bucket_timestamp since until].freeze + DEFAULT_GROUP_BY = V2::Reports::DrilldownBuilder::DEFAULT_GROUP_BY + SUPPORTED_GROUP_BY = V2::Reports::DrilldownBuilder::SUPPORTED_GROUP_BY + + module_function + + def valid?(params) + timestamps = TIMESTAMP_PARAMS.index_with { |param| integer_param(params[param]) } + return false if timestamps.values.any?(&:nil?) + return false unless timestamps[:since] < timestamps[:until] + + bucket_overlaps_requested_range?(params, timestamps) + end + + def integer_param(value) + return unless value.to_s.match?(/\A\d+\z/) + + value.to_i + end + + def bucket_overlaps_requested_range?(params, timestamps) + bucket_start = Time.zone.at(timestamps[:bucket_timestamp]).in_time_zone(timezone(params)) + bucket_end = bucket_end_for(bucket_start, group_by(params)) + requested_start = Time.zone.at(timestamps[:since]) + requested_end = Time.zone.at(timestamps[:until]) + + bucket_start < requested_end && bucket_end > requested_start + rescue ArgumentError, RangeError + false + end + + def bucket_end_for(bucket_start, group_by) + { + '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 group_by(params) + group = params[:group_by].to_s + SUPPORTED_GROUP_BY.include?(group) ? group : DEFAULT_GROUP_BY + end + + def timezone(params) + timezone_name_from_offset(params[:timezone_offset]) + end +end diff --git a/app/services/user_session_tracking_service.rb b/app/services/user_session_tracking_service.rb index d7e6f6efb..b84693b59 100644 --- a/app/services/user_session_tracking_service.rb +++ b/app/services/user_session_tracking_service.rb @@ -31,6 +31,14 @@ class UserSessionTrackingService private def session_attributes + client_headers = mobile_client_headers + if client_headers + return client_headers.merge( + ip_address: @request.remote_ip, + user_agent: @request.user_agent + ) + end + browser = Browser.new(@request.user_agent) attrs = { @@ -46,6 +54,30 @@ class UserSessionTrackingService patch_for_legacy_mobile(attrs) end + def mobile_client_headers + name = @request.headers['X-Chatwoot-Client-Name'] + return nil if name.blank? + + platform = @request.headers['X-Chatwoot-Platform'] + model = @request.headers['X-Chatwoot-Device-Model'] + + { + browser_name: name, + browser_version: @request.headers['X-Chatwoot-Client-Version'], + device_name: device_name_for_icon(platform, model), + platform_name: model, + platform_version: @request.headers['X-Chatwoot-Platform-Version'] + } + end + + def device_name_for_icon(platform, model) + normalized_platform = platform.to_s.downcase + return 'iPad' if normalized_platform == 'ios' && model.to_s.include?('iPad') + return 'iPhone' if normalized_platform == 'ios' + + 'Android' + end + def patch_for_legacy_mobile(attrs) return attrs unless attrs[:browser_name] == 'Unknown Browser' diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb index 2e8a5028e..495d36975 100644 --- a/app/services/whatsapp/embedded_signup_service.rb +++ b/app/services/whatsapp/embedded_signup_service.rb @@ -47,7 +47,7 @@ class Whatsapp::EmbeddedSignupService account: @account, inbox_id: @inbox_id, phone_number_id: @phone_number_id, - business_id: @business_id + waba_id: @waba_id ).perform(access_token, phone_info) else waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] } diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb index 22e75aac0..7e74e8ac6 100644 --- a/app/services/whatsapp/facebook_api_client.rb +++ b/app/services/whatsapp/facebook_api_client.rb @@ -1,5 +1,7 @@ class Whatsapp::FacebookApiClient BASE_URI = 'https://graph.facebook.com'.freeze + # Base webhook fields resent on every subscribe so Meta won't reset to defaults. `calls` is added by callers only when voice is enabled. + WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes].freeze def initialize(access_token = nil) @access_token = access_token @@ -60,48 +62,62 @@ class Whatsapp::FacebookApiClient data['code_verification_status'] == 'VERIFIED' end - WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes].freeze + def subscribe_phone_number_webhook(waba_id, phone_number_id, callback_url, verify_token, subscribed_fields: nil) + # Subscribe app to WABA first — Meta requires it before any callback override (issue #13097). + # subscribed_fields (incl. `calls` when voice is enabled) is declared here; the phone-level POST has no such field. + subscribe_app_to_waba(waba_id, subscribed_fields: subscribed_fields || WEBHOOK_DEFAULT_FIELDS) - def subscribe_waba_webhook(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS) - # Step 1: Subscribe app to WABA first (required before override) - # Meta requires the app to be subscribed before using override_callback_uri - # See: https://github.com/chatwoot/chatwoot/issues/13097 - subscribe_app_to_waba(waba_id) - - # Step 2: Override callback URL for this specific WABA - override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields) + # Phone-level override takes precedence over WABA-level, so numbers on one WABA can route to different URLs. + override_phone_number_callback(phone_number_id, callback_url, verify_token) end - def subscribe_app_to_waba(waba_id) + def subscribe_app_to_waba(waba_id, subscribed_fields: WEBHOOK_DEFAULT_FIELDS) response = HTTParty.post( "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps", - headers: request_headers + headers: request_headers, + body: { subscribed_fields: subscribed_fields }.to_json ) handle_response(response, 'App subscription to WABA failed') end - def override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS) + def override_phone_number_callback(phone_number_id, callback_url, verify_token) response = HTTParty.post( - "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps", + "#{BASE_URI}/#{@api_version}/#{phone_number_id}", headers: request_headers, body: { - override_callback_uri: callback_url, - verify_token: verify_token, - subscribed_fields: subscribed_fields + webhook_configuration: { + override_callback_uri: callback_url, + verify_token: verify_token + } }.to_json ) - handle_response(response, 'Webhook callback override failed') + handle_response(response, 'Phone number webhook callback override failed') end - def unsubscribe_waba_webhook(waba_id) + def clear_phone_number_callback_override(phone_number_id) + response = HTTParty.post( + "#{BASE_URI}/#{@api_version}/#{phone_number_id}", + headers: request_headers, + body: { + webhook_configuration: { + override_callback_uri: '' + } + }.to_json + ) + + handle_response(response, 'Phone number webhook callback clear failed') + end + + # Fully removes this app's WABA subscription (last inbox deleted) so Meta stops delivering webhooks. + def unsubscribe_app_from_waba(waba_id) response = HTTParty.delete( "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps", headers: request_headers ) - handle_response(response, 'Webhook unsubscription failed') + handle_response(response, 'WABA app unsubscription failed') end private diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 7981f54b5..d65c6cc62 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -40,7 +40,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi def fetch_whatsapp_templates(url) response = HTTParty.get(url) - return [] unless response.success? + unless response.success? + Rails.logger.warn "[WHATSAPP] Template sync failed for account #{whatsapp_channel.account_id} " \ + "inbox #{whatsapp_channel.inbox&.id}: #{response.code} #{error_message(response)}" + return [] + end next_url = next_url(response) @@ -54,8 +58,17 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi end def validate_provider_config? - response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}") - response.success? + config = whatsapp_channel.provider_config + response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{config['api_key']}") + return log_transfer_failure('waba_or_token_check', response) unless response.success? + # The templates check only proves the WABA/token pair, so verify the phone_number_id belongs to this WABA when it changes. + return true unless whatsapp_channel.provider_config_changed? + + phone_response = HTTParty.get("#{business_account_path}/phone_numbers?fields=id&limit=100&access_token=#{config['api_key']}") + ids = phone_response.parsed_response.is_a?(Hash) ? Array(phone_response.parsed_response['data']) : [] + return true if phone_response.success? && ids.any? { |number| number['id'] == config['phone_number_id'].to_s } + + log_transfer_failure('phone_number_id_check', phone_response) end def api_headers @@ -81,6 +94,16 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi private + # Only saves dropping the embedded_signup source marker are transfer attempts; creation/rotation failures are setup errors. Returns false. + def log_transfer_failure(check, response) + return false unless whatsapp_channel.embedded_to_manual_transfer_pending? + + error_message = response.parsed_response.is_a?(Hash) ? response.parsed_response.dig('error', 'message') : nil + Rails.logger.warn("[WHATSAPP_EMBEDDED_TO_MANUAL] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \ + "check=#{check} http_status=#{response.code} meta_error=#{error_message}") + false + end + def csat_template_service @csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel) end @@ -136,7 +159,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi def error_message(response) # https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response - response.parsed_response&.dig('error', 'message') + response.parsed_response.dig('error', 'message') if response.parsed_response.is_a?(Hash) end def voice_message?(type, attachment) diff --git a/app/services/whatsapp/reauthorization_service.rb b/app/services/whatsapp/reauthorization_service.rb index aeb6dfbef..81be85f50 100644 --- a/app/services/whatsapp/reauthorization_service.rb +++ b/app/services/whatsapp/reauthorization_service.rb @@ -1,9 +1,9 @@ class Whatsapp::ReauthorizationService - def initialize(account:, inbox_id:, phone_number_id:, business_id:) + def initialize(account:, inbox_id:, phone_number_id:, waba_id:) @account = account @inbox_id = inbox_id @phone_number_id = phone_number_id - @business_id = business_id + @waba_id = waba_id end def perform(access_token, phone_info) @@ -27,10 +27,13 @@ class Whatsapp::ReauthorizationService def update_channel_config(channel, access_token, phone_info) current_config = channel.provider_config || {} + # Legacy clients may omit phone_number_id; fall back to the value just fetched from Meta. + resolved_phone_number_id = @phone_number_id.presence || phone_info[:phone_number_id] + channel.provider_config = current_config.merge( 'api_key' => access_token, - 'phone_number_id' => @phone_number_id, - 'business_account_id' => @business_id, + 'phone_number_id' => resolved_phone_number_id, + 'business_account_id' => @waba_id, 'source' => 'embedded_signup' ) channel.save! diff --git a/app/services/whatsapp/webhook_setup_service.rb b/app/services/whatsapp/webhook_setup_service.rb index 2abf113da..7bf93c62d 100644 --- a/app/services/whatsapp/webhook_setup_service.rb +++ b/app/services/whatsapp/webhook_setup_service.rb @@ -28,6 +28,7 @@ class Whatsapp::WebhookSetupService raise ArgumentError, 'Channel is required' if @channel.blank? raise ArgumentError, 'WABA ID is required' if @waba_id.blank? raise ArgumentError, 'Access token is required' if @access_token.blank? + raise ArgumentError, 'Phone number ID is required' if @channel.provider_config['phone_number_id'].blank? end def register_phone_number @@ -58,8 +59,9 @@ class Whatsapp::WebhookSetupService def setup_webhook callback_url = build_callback_url verify_token = @channel.provider_config['webhook_verify_token'] + phone_number_id = @channel.provider_config['phone_number_id'] - @api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields) + @api_client.subscribe_phone_number_webhook(@waba_id, phone_number_id, callback_url, verify_token, subscribed_fields: subscribed_fields) rescue StandardError => e Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}") raise "Webhook setup failed: #{e.message}" @@ -68,10 +70,24 @@ class Whatsapp::WebhookSetupService # Subscribe to `calls` only when voice calling is enabled on the inbox def subscribed_fields fields = %w[messages smb_message_echoes] - fields << 'calls' if @channel.provider_config['calling_enabled'] + fields << 'calls' if calls_enabled_on_waba? fields end + # `subscribed_fields` is a WABA-wide app subscription, so keep `calls` whenever this inbox or + # any sibling on the same WABA has voice on — otherwise a non-calling sibling's setup would + # rewrite the shared subscription and drop calls for a calling-enabled sibling. + def calls_enabled_on_waba? + return true if @channel.provider_config['calling_enabled'] + + Channel::Whatsapp + .where(provider: 'whatsapp_cloud') + .where.not(id: @channel.id) + .where("provider_config->>'business_account_id' = ?", @waba_id) + .where("provider_config->>'calling_enabled' = 'true'") + .exists? + end + def build_callback_url frontend_url = ENV.fetch('FRONTEND_URL', nil) phone_number = @channel.phone_number diff --git a/app/services/whatsapp/webhook_teardown_service.rb b/app/services/whatsapp/webhook_teardown_service.rb index c4a39a5eb..de794f8e3 100644 --- a/app/services/whatsapp/webhook_teardown_service.rb +++ b/app/services/whatsapp/webhook_teardown_service.rb @@ -6,42 +6,55 @@ class Whatsapp::WebhookTeardownService def perform return unless should_teardown_webhook? - teardown_webhook + api_client = Whatsapp::FacebookApiClient.new(provider_config['api_key']) + + clear_phone_number_override(api_client) + unsubscribe_app_if_last_inbox(api_client) rescue StandardError => e - handle_webhook_teardown_error(e) + # before_destroy must never block a channel delete — log and move on. + Rails.logger.error "[WHATSAPP] Webhook teardown failed for channel #{@channel&.id}: #{e.message}" end private + def provider_config + @channel.provider_config || {} + end + def should_teardown_webhook? - whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present? + @channel.provider == 'whatsapp_cloud' && + provider_config['api_key'].present? && + (provider_config['phone_number_id'].present? || provider_config['business_account_id'].present?) end - def whatsapp_cloud_provider? - @channel.provider == 'whatsapp_cloud' + def clear_phone_number_override(api_client) + phone_number_id = provider_config['phone_number_id'] + return if phone_number_id.blank? + + api_client.clear_phone_number_callback_override(phone_number_id) + Rails.logger.info "[WHATSAPP] Phone-level webhook override cleared for channel #{@channel.id}" + rescue StandardError => e + Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}" end - def embedded_signup_source? - @channel.provider_config['source'] == 'embedded_signup' + # Embedded signup only — a manual token's subscribed app is the customer's, not ours to unsubscribe. + # The subscription is shared across the WABA, so only unsubscribe when this is the last inbox. + def unsubscribe_app_if_last_inbox(api_client) + return unless provider_config['source'] == 'embedded_signup' + + waba_id = provider_config['business_account_id'] + return if waba_id.blank? + return if waba_sibling_exists?(waba_id) + + api_client.unsubscribe_app_from_waba(waba_id) + Rails.logger.info "[WHATSAPP] WABA app subscription removed for channel #{@channel.id}" + rescue StandardError => e + Rails.logger.error "[WHATSAPP] WABA app unsubscribe failed for channel #{@channel.id}: #{e.message}" end - def webhook_config_present? - @channel.provider_config['business_account_id'].present? && - @channel.provider_config['api_key'].present? - end - - def teardown_webhook - waba_id = @channel.provider_config['business_account_id'] - access_token = @channel.provider_config['api_key'] - api_client = Whatsapp::FacebookApiClient.new(access_token) - - api_client.unsubscribe_waba_webhook(waba_id) - Rails.logger.info "[WHATSAPP] Webhook unsubscribed successfully for channel #{@channel.id}" - end - - def handle_webhook_teardown_error(error) - Rails.logger.error "[WHATSAPP] Webhook teardown failed: #{error.message}" - # Don't raise the error to prevent channel deletion from failing - # Failed webhook teardown shouldn't block deletion + def waba_sibling_exists?(waba_id) + Channel::Whatsapp + .where.not(id: @channel.id) + .exists?(["provider_config ->> 'business_account_id' = ?", waba_id]) end end diff --git a/app/views/api/v1/accounts/articles/_article.json.jbuilder b/app/views/api/v1/accounts/articles/_article.json.jbuilder index a6eb7a551..426b24afe 100644 --- a/app/views/api/v1/accounts/articles/_article.json.jbuilder +++ b/app/views/api/v1/accounts/articles/_article.json.jbuilder @@ -4,6 +4,8 @@ json.title article.title json.content article.content json.description article.description json.status article.status +json.draft_title article.draft_title +json.draft_content article.draft_content json.position article.position json.account_id article.account_id json.updated_at article.updated_at.to_i diff --git a/app/views/api/v1/accounts/assignable_agents/index.json.jbuilder b/app/views/api/v1/accounts/assignable_agents/index.json.jbuilder index 295c5cef9..1a00c7122 100644 --- a/app/views/api/v1/accounts/assignable_agents/index.json.jbuilder +++ b/app/views/api/v1/accounts/assignable_agents/index.json.jbuilder @@ -1,5 +1,16 @@ json.payload do - json.array! @assignable_agents do |agent| - json.partial! 'api/v1/models/agent', formats: [:json], resource: agent + owners = @assignable_agents.map { |agent| { type: 'User', resource: agent } } + owners += @agent_bots.map { |agent_bot| { type: 'AgentBot', resource: agent_bot } } + + json.array! owners do |owner| + if owner[:type] == 'User' + json.partial! 'api/v1/models/agent', formats: [:json], resource: owner[:resource] + json.assignee_type 'User' if @include_agent_bots + else + json.partial! 'api/v1/models/agent_bot_slim', formats: [:json], resource: owner[:resource] + json.assignee_type 'AgentBot' + json.icon 'i-lucide-bot' + json.availability_status 'offline' + end end end diff --git a/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder b/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder index cf09a2949..b55c229b1 100644 --- a/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder +++ b/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder @@ -5,6 +5,7 @@ json.assignment_order assignment_policy.assignment_order json.conversation_priority assignment_policy.conversation_priority json.fair_distribution_limit assignment_policy.fair_distribution_limit json.fair_distribution_window assignment_policy.fair_distribution_window +json.exclude_older_than_hours assignment_policy.exclude_older_than_hours json.enabled assignment_policy.enabled json.assigned_inbox_count assignment_policy.inboxes.count json.created_at assignment_policy.created_at.to_i diff --git a/app/views/api/v1/accounts/branded_email_layouts/show.json.jbuilder b/app/views/api/v1/accounts/branded_email_layouts/show.json.jbuilder new file mode 100644 index 000000000..7a30a0b14 --- /dev/null +++ b/app/views/api/v1/accounts/branded_email_layouts/show.json.jbuilder @@ -0,0 +1 @@ +json.branded_email_layout @branded_email_layout diff --git a/app/views/api/v1/accounts/branded_email_layouts/update.json.jbuilder b/app/views/api/v1/accounts/branded_email_layouts/update.json.jbuilder new file mode 100644 index 000000000..7a30a0b14 --- /dev/null +++ b/app/views/api/v1/accounts/branded_email_layouts/update.json.jbuilder @@ -0,0 +1 @@ +json.branded_email_layout @branded_email_layout diff --git a/app/views/api/v1/accounts/data_imports/_data_import.json.jbuilder b/app/views/api/v1/accounts/data_imports/_data_import.json.jbuilder new file mode 100644 index 000000000..ba56ac4d0 --- /dev/null +++ b/app/views/api/v1/accounts/data_imports/_data_import.json.jbuilder @@ -0,0 +1,24 @@ +json.id data_import.id +json.name data_import.name +json.data_type data_import.data_type +json.source_type data_import.source_type +json.source_provider data_import.source_provider +json.import_types data_import.import_types +json.status data_import.status +json.total_records data_import.total_records +json.processed_records data_import.processed_records +json.stats data_import.stats +json.cursor data_import.cursor +json.created_at data_import.created_at +json.updated_at data_import.updated_at +json.started_at data_import.started_at +json.completed_at data_import.completed_at +json.abandoned_at data_import.abandoned_at +json.initiated_by data_import.initiated_by&.slice(:id, :name, :email) +if @import_errors_counts + json.import_errors_count @import_errors_counts.fetch(data_import.id, 0) + json.skip_logs_count (@skip_logs_counts || {}).fetch(data_import.id, 0) +else + json.import_errors_count data_import.import_errors.non_skip_logs.count + json.skip_logs_count data_import.import_errors.skip_logs.count +end diff --git a/app/views/api/v1/accounts/data_imports/index.json.jbuilder b/app/views/api/v1/accounts/data_imports/index.json.jbuilder new file mode 100644 index 000000000..beaf27125 --- /dev/null +++ b/app/views/api/v1/accounts/data_imports/index.json.jbuilder @@ -0,0 +1,5 @@ +json.payload do + json.array! @data_imports do |data_import| + json.partial! 'api/v1/accounts/data_imports/data_import', formats: [:json], data_import: data_import + end +end diff --git a/app/views/api/v1/accounts/data_imports/show.json.jbuilder b/app/views/api/v1/accounts/data_imports/show.json.jbuilder new file mode 100644 index 000000000..d14ae2fe3 --- /dev/null +++ b/app/views/api/v1/accounts/data_imports/show.json.jbuilder @@ -0,0 +1,31 @@ +json.partial! 'api/v1/accounts/data_imports/data_import', formats: [:json], data_import: @data_import + +json.import_errors do + json.array! @import_errors_finder.import_errors do |import_error| + json.id import_error.id + json.error_code import_error.error_code + json.message import_error.message + json.source_object_type import_error.source_object_type + json.source_object_id import_error.source_object_id + json.details import_error.details + json.created_at import_error.created_at + end +end + +json.skip_logs do + json.array! @skip_logs_finder.skip_logs do |skip_log| + json.id skip_log.id + json.kind skip_log.details['kind'] + json.error_code skip_log.error_code + json.message skip_log.message + json.source_object_type skip_log.source_object_type + json.source_object_id skip_log.source_object_id + json.details skip_log.details + json.created_at skip_log.created_at + end +end + +json.skip_logs_filters do + json.selected_source_object_type @skip_logs_finder.selected_source_object_type + json.counts_by_type @skip_logs_finder.counts_by_type +end diff --git a/app/views/api/v1/accounts/inboxes/show.json.jbuilder b/app/views/api/v1/accounts/inboxes/show.json.jbuilder index 2ad94ff82..4837c8c02 100644 --- a/app/views/api/v1/accounts/inboxes/show.json.jbuilder +++ b/app/views/api/v1/accounts/inboxes/show.json.jbuilder @@ -1 +1 @@ -json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox +json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox, with_branded_email_layout: true diff --git a/app/views/api/v1/accounts/inboxes/update.json.jbuilder b/app/views/api/v1/accounts/inboxes/update.json.jbuilder index 2ad94ff82..4837c8c02 100644 --- a/app/views/api/v1/accounts/inboxes/update.json.jbuilder +++ b/app/views/api/v1/accounts/inboxes/update.json.jbuilder @@ -1 +1 @@ -json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox +json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox, with_branded_email_layout: true diff --git a/app/views/api/v1/accounts/portals/_portal.json.jbuilder b/app/views/api/v1/accounts/portals/_portal.json.jbuilder index 93626ee36..2e4e78f1c 100644 --- a/app/views/api/v1/accounts/portals/_portal.json.jbuilder +++ b/app/views/api/v1/accounts/portals/_portal.json.jbuilder @@ -19,6 +19,7 @@ json.config do json.layout portal.layout json.social_profiles portal.social_profiles json.locale_translations portal.config['locale_translations'] || {} + json.popular_content portal.config['popular_content'] || {} end if portal.channel_web_widget diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index 4cb13f543..5fdd4ecee 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -58,5 +58,6 @@ json.last_non_activity_message conversation.messages.where(account_id: conversat json.last_activity_at conversation.last_activity_at.to_i json.priority conversation.priority json.waiting_since conversation.waiting_since.to_i.to_i -json.sla_policy_id conversation.sla_policy_id +sla_applicable = !conversation.respond_to?(:sla_applicable?) || conversation.sla_applicable? +json.sla_policy_id sla_applicable ? conversation.sla_policy_id : nil json.partial! 'enterprise/api/v1/conversations/partials/conversation', conversation: conversation if ChatwootApp.enterprise? diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index 02b3480d5..95beee1fa 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -6,6 +6,7 @@ if resource.custom_attributes.present? json.subscribed_quantity resource.custom_attributes['subscribed_quantity'] json.subscription_status resource.custom_attributes['subscription_status'] json.subscription_ends_on resource.custom_attributes['subscription_ends_on'] + json.billing_currency resource.billing_currency if resource.respond_to?(:billing_currency) && Enterprise::Billing::Currencies.enabled? json.website resource.custom_attributes['website'] if resource.custom_attributes['website'].present? json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present? json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present? diff --git a/app/views/api/v1/models/_agent_bot.json.jbuilder b/app/views/api/v1/models/_agent_bot.json.jbuilder index d5dbc91b4..2137ca107 100644 --- a/app/views/api/v1/models/_agent_bot.json.jbuilder +++ b/app/views/api/v1/models/_agent_bot.json.jbuilder @@ -6,6 +6,6 @@ json.outgoing_url resource.outgoing_url unless resource.system_bot? json.bot_type resource.bot_type json.bot_config resource.bot_config json.account_id resource.account_id -json.access_token resource.access_token if resource.access_token.present? +json.access_token resource.access_token if resource.access_token.present? && Current.account_user&.administrator? json.secret resource.secret if !resource.system_bot? && Current.account_user&.administrator? json.system_bot resource.system_bot? diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 677e4fe9e..216f7db76 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -81,6 +81,10 @@ if resource.email? json.email resource.channel.try(:email) json.forwarding_enabled ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present? json.forward_to_email resource.channel.try(:forward_to_email) if ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present? + if Current.account_user&.administrator? && defined?(with_branded_email_layout) && with_branded_email_layout.present? && + Current.account.feature_enabled?(:branded_email_templates) + json.branded_email_layout resource.branded_email_layout + end ## IMAP if Current.account_user&.administrator? diff --git a/app/views/api/v1/models/_team.json.jbuilder b/app/views/api/v1/models/_team.json.jbuilder index 9aaab89e8..911648bc4 100644 --- a/app/views/api/v1/models/_team.json.jbuilder +++ b/app/views/api/v1/models/_team.json.jbuilder @@ -2,5 +2,7 @@ json.id resource.id json.name resource.name json.description resource.description json.allow_auto_assign resource.allow_auto_assign +json.icon resource.icon +json.icon_color resource.icon_color json.account_id resource.account_id json.is_member Current.user.teams.include?(resource) diff --git a/app/views/api/v1/models/_user.json.jbuilder b/app/views/api/v1/models/_user.json.jbuilder index e856031c5..e4f795566 100644 --- a/app/views/api/v1/models/_user.json.jbuilder +++ b/app/views/api/v1/models/_user.json.jbuilder @@ -1,4 +1,4 @@ -json.access_token resource.access_token.token +json.access_token resource.accounts.any?(&:api_and_webhooks_enabled?) ? resource.access_token.token : '' json.account_id resource.active_account_user&.account_id json.available_name resource.available_name json.avatar_url resource.avatar_url @@ -31,6 +31,7 @@ json.accounts do # availability derived from presence json.availability_status account_user.availability_status json.auto_offline account_user.auto_offline + json.api_and_webhooks account_user.account.feature_enabled?('api_and_webhooks') json.partial! 'api/v1/models/account_user', account_user: account_user if ChatwootApp.enterprise? end end diff --git a/app/views/layouts/_portal_scripts.html.erb b/app/views/layouts/_portal_scripts.html.erb index b1479cace..2df4a02fd 100644 --- a/app/views/layouts/_portal_scripts.html.erb +++ b/app/views/layouts/_portal_scripts.html.erb @@ -67,6 +67,11 @@ html.light { #category-block:hover #category-name { color: var(--dynamic-hover-color); } +/* Recommended topic pills in the classic hero */ +.recommended-pill:hover { + border-color: var(--dynamic-hover-color); + color: var(--dynamic-hover-color); +} ', + 'utm_campaign' => 'launch & learn' + ) + + described_class.new(account: account, cookies: cookies).perform + + attribution = account.reload.internal_attributes['marketing_attribution'] + expect(attribution['last_touch']['source']).to eq('') + expect(attribution['last_touch']['utm_campaign']).to eq('launch & learn') + end + + it 'caps raw attribution values' do + cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie( + 'source' => 'google', + 'utm_campaign' => '&' * 600 + ) + + described_class.new(account: account, cookies: cookies).perform + + attribution = account.reload.internal_attributes['marketing_attribution'] + expect(attribution['last_touch']['utm_campaign'].length).to eq(described_class::FIELD_MAX_LENGTH) + end + + def encoded_cookie(payload) + Base64.urlsafe_encode64(payload.to_json, padding: false) + end +end diff --git a/spec/enterprise/services/internal/accounts/marketing_conversion_tracking_service_spec.rb b/spec/enterprise/services/internal/accounts/marketing_conversion_tracking_service_spec.rb new file mode 100644 index 000000000..624af0d7f --- /dev/null +++ b/spec/enterprise/services/internal/accounts/marketing_conversion_tracking_service_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Internal::Accounts::MarketingConversionTrackingService do + let(:account) { create(:account) } + let(:event_name) { 'cloud_signup' } + let(:occurred_at) { Time.zone.parse('2026-06-23T10:30:00Z') } + let(:private_key) { OpenSSL::PKey::RSA.new(2048).to_pem } + let(:credentials) do + instance_double(Google::Auth::ServiceAccountCredentials, fetch_access_token!: { 'access_token' => 'access-token' }) + end + let(:config) do + { + 'customer_id' => '852-320-2898', + 'login_customer_id' => '742-202-9198', + 'service_account_credentials' => { + 'client_email' => 'marketing-conversions@chatwoot-production.iam.gserviceaccount.com', + 'private_key' => private_key + }, + 'events' => { + 'cloud_signup' => { + 'conversion_action_id' => '123456789' + } + } + } + end + let(:marketing_attribution) do + { + 'first_touch' => { 'gclid' => 'first-click' }, + 'last_touch' => { 'gclid' => 'last-click' } + } + end + + before do + create(:installation_config, name: described_class::CONFIG_KEY, value: config.to_json) + account.update!(internal_attributes: { 'marketing_attribution' => marketing_attribution }) + + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).and_return(credentials) + end + + it 'does nothing outside Chatwoot Cloud' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) + + expect(HTTParty).not_to receive(:post) + + described_class.new(account: account, event_name: event_name, occurred_at: occurred_at).perform + end + + it 'uploads the last-touch click conversion', :aggregate_failures do + upload_request = nil + + allow(HTTParty).to receive(:post) do |url, options| + upload_request = [url, options] + instance_double(HTTParty::Response, success?: true, body: '{}') + end + + described_class.new( + account: account, + event_name: event_name, + occurred_at: occurred_at, + conversion_value: 199, + currency_code: 'USD' + ).perform + + url, options = upload_request + body = JSON.parse(options[:body]) + + expect(url).to eq('https://datamanager.googleapis.com/v1/events:ingest') + expect(Google::Auth::ServiceAccountCredentials).to have_received(:make_creds).with( + json_key_io: kind_of(StringIO), + scope: ['https://www.googleapis.com/auth/datamanager'] + ) + expect(options[:headers]).to include( + 'Authorization' => 'Bearer access-token' + ) + expect(body['destinations'].first).to include( + 'operatingAccount' => { + 'accountType' => 'GOOGLE_ADS', + 'accountId' => '8523202898' + }, + 'loginAccount' => { + 'accountType' => 'GOOGLE_ADS', + 'accountId' => '7422029198' + }, + 'productDestinationId' => '123456789' + ) + expect(body['events'].first).to include( + 'transactionId' => "cloud_signup-account-#{account.id}", + 'eventTimestamp' => '2026-06-23T10:30:00Z', + 'eventSource' => 'WEB', + 'adIdentifiers' => { 'gclid' => 'last-click' }, + 'conversionValue' => 199.0, + 'currency' => 'USD' + ) + end + + it 'falls back to first-touch attribution when last-touch attribution has no click id' do + account.update!( + internal_attributes: { + 'marketing_attribution' => { + 'last_touch' => { 'source' => 'github' }, + 'first_touch' => { 'gclid' => 'first-click' } + } + } + ) + upload_body = nil + + allow(HTTParty).to receive(:post) do |_url, options| + upload_body = JSON.parse(options[:body]) + instance_double(HTTParty::Response, success?: true, body: '{}') + end + + described_class.new(account: account, event_name: event_name, occurred_at: occurred_at).perform + + expect(upload_body['events'].first['adIdentifiers']['gclid']).to eq('first-click') + end +end diff --git a/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb b/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb index 63a318415..76e9e3602 100644 --- a/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb +++ b/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb @@ -11,14 +11,16 @@ RSpec.describe Internal::ReconcilePlanConfigService do it 'disables the premium features for accounts' do account = create(:account) - account.enable_features!('disable_branding', 'audit_logs', 'captain_integration') + account.enable_features!('disable_branding', 'audit_logs', 'captain_integration', 'captain_integration_v2') account_with_captain = create(:account) - account_with_captain.enable_features!('captain_integration') + account_with_captain.enable_features!('captain_integration', 'captain_integration_v2') disable_branding_account = create(:account) disable_branding_account.enable_features!('disable_branding') service.perform - expect(account.reload.enabled_features.keys).not_to include('captain_integration', 'disable_branding', 'audit_logs') - expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration') + expect(account.reload.enabled_features.keys).not_to include( + 'captain_integration', 'captain_integration_v2', 'disable_branding', 'audit_logs' + ) + expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration', 'captain_integration_v2') expect(disable_branding_account.reload.enabled_features.keys).not_to include('disable_branding') end @@ -56,14 +58,16 @@ RSpec.describe Internal::ReconcilePlanConfigService do it 'does not disable the premium features for accounts' do account = create(:account) - account.enable_features!('disable_branding', 'audit_logs', 'captain_integration') + account.enable_features!('disable_branding', 'audit_logs', 'captain_integration', 'captain_integration_v2') account_with_captain = create(:account) - account_with_captain.enable_features!('captain_integration') + account_with_captain.enable_features!('captain_integration', 'captain_integration_v2') disable_branding_account = create(:account) disable_branding_account.enable_features!('disable_branding') service.perform - expect(account.reload.enabled_features.keys).to include('captain_integration', 'disable_branding', 'audit_logs') - expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration') + expect(account.reload.enabled_features.keys).to include( + 'captain_integration', 'captain_integration_v2', 'disable_branding', 'audit_logs' + ) + expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration', 'captain_integration_v2') expect(disable_branding_account.reload.enabled_features.keys).to include('disable_branding') end diff --git a/spec/enterprise/services/llm/base_ai_service_spec.rb b/spec/enterprise/services/llm/base_ai_service_spec.rb index c45fff522..d4c07d336 100644 --- a/spec/enterprise/services/llm/base_ai_service_spec.rb +++ b/spec/enterprise/services/llm/base_ai_service_spec.rb @@ -3,10 +3,46 @@ require 'rails_helper' RSpec.describe Llm::BaseAiService do subject(:service) { described_class.new } + let(:account) { create(:account) } + before do + InstallationConfig.where(name: %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL]).destroy_all create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') end + describe '#initialize' do + it 'uses the installation model when no feature is provided' do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano') + + expect(described_class.new.model).to eq('gpt-4.1-nano') + end + + it 'uses the account override when feature context is provided' do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano') + account.update!(captain_models: { 'assistant' => 'gpt-5.2' }) + + expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2') + end + + it 'uses the installation model when feature context has no account override' do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano') + + expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-4.1-nano') + end + + it 'uses the Captain V2 assistant default ahead of the installation model' do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano') + account.enable_features!('captain_integration_v2') + + expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2') + expect(account.reload.captain_models).to be_nil + end + + it 'uses the feature default when feature context has no account override or installation model' do + expect(described_class.new(feature: 'assistant', account: account).model).to eq(Llm::Models.default_model_for('assistant')) + end + end + describe '#sanitize_json_response' do it 'strips ```json fences' do input = "```json\n{\"key\": \"value\"}\n```" diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb index 32752c2b2..4881e8cf1 100644 --- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb +++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Messages::AudioTranscriptionService, type: :service do let(:account) { create(:account, audio_transcriptions: true) } let(:conversation) { create(:conversation, account: account) } - let(:message) { create(:message, conversation: conversation) } + let(:message) { create(:message, account: account, conversation: conversation) } let(:attachment) { message.attachments.create!(account: account, file_type: :audio) } before do @@ -12,7 +12,13 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_MODEL') { |config| config.value = 'gpt-4o-mini' } # Mock usage limits for transcription to be available - allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } }) + allow(account).to receive(:usage_limits).and_return( + { + agents: ChatwootApp.max_limit, + inboxes: ChatwootApp.max_limit, + captain: { responses: { current_available: 100 } } + } + ) end describe '#perform' do @@ -101,4 +107,29 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do FileUtils.rm_f(temp_file_path) if temp_file_path.present? end end + + describe '#transcribe_audio' do + let(:service) { described_class.new(attachment) } + let(:audio_api) { double('audio_api') } # rubocop:disable RSpec/VerifiedDoubles + let(:audio_file_path) { Rails.root.join('tmp/audio_transcription_service_spec.mp3').to_s } + + before do + File.binwrite(audio_file_path, 'audio') + allow(service).to receive(:fetch_audio_file).and_return(audio_file_path) + allow(service).to receive(:update_transcription) + allow(service.client).to receive(:audio).and_return(audio_api) + end + + after do + FileUtils.rm_f(audio_file_path) + end + + it 'uses the audio transcription feature model' do + expect(audio_api).to receive(:transcribe).with( + parameters: hash_including(model: 'gpt-4o-mini-transcribe', temperature: 0.0) + ).and_return({ 'text' => 'Audio transcript' }) + + expect(service.send(:transcribe_audio)).to eq('Audio transcript') + end + end end diff --git a/spec/enterprise/services/sla/business_hours_service_spec.rb b/spec/enterprise/services/sla/business_hours_service_spec.rb new file mode 100644 index 000000000..31205c934 --- /dev/null +++ b/spec/enterprise/services/sla/business_hours_service_spec.rb @@ -0,0 +1,184 @@ +require 'rails_helper' + +RSpec.describe Sla::BusinessHoursService do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account, working_hours_enabled: true, timezone: 'UTC') } + + # Default working hours: Mon-Fri 9:00-17:00 UTC, Sat-Sun closed + describe '#deadline' do + context 'when business hours should not apply' do + it 'returns wall-clock deadline when working_hours_enabled is false' do + inbox.update!(working_hours_enabled: false) + start_time = Time.zone.parse('2024-01-19 16:00:00') + + service = described_class.new(inbox: inbox, start_time: start_time, threshold_seconds: 3600) + + expect(service.deadline.to_i).to eq((start_time + 1.hour).to_i) + end + + it 'returns wall-clock deadline when all days are closed' do + inbox.working_hours.find_each { |wh| wh.update!(closed_all_day: true) } + start_time = Time.zone.parse('2024-01-19 16:00:00') + + service = described_class.new(inbox: inbox, start_time: start_time, threshold_seconds: 3600) + + expect(service.deadline.to_i).to eq((start_time + 1.hour).to_i) + end + end + + context 'when start time is during business hours' do + it 'calculates deadline within the same day' do + # Wednesday 10:00 AM + 2 hours = Wednesday 12:00 PM + start_time = Time.zone.parse('2024-01-17 10:00:00') # Wednesday + expected_deadline = Time.zone.parse('2024-01-17 12:00:00') + + service = described_class.new(inbox: inbox, start_time: start_time, threshold_seconds: 2.hours) + + expect(service.deadline.to_i).to eq(expected_deadline.to_i) + end + + it 'spans to next business day when threshold exceeds remaining hours' do + # Friday 4:00 PM + 2 hours = Monday 10:00 AM (1h Friday + 1h Monday) + friday_4pm = Time.zone.parse('2024-01-19 16:00:00') + monday_10am = Time.zone.parse('2024-01-22 10:00:00') + + service = described_class.new(inbox: inbox, start_time: friday_4pm, threshold_seconds: 2.hours) + + expect(service.deadline.to_i).to eq(monday_10am.to_i) + end + end + + context 'when start time is before business hours' do + it 'starts counting from business hours open time' do + # Wednesday 7:00 AM + 2 hours = Wednesday 11:00 AM (starts at 9 AM) + start_time = Time.zone.parse('2024-01-17 07:00:00') # Wednesday 7 AM + expected_deadline = Time.zone.parse('2024-01-17 11:00:00') # Wednesday 11 AM + + service = described_class.new(inbox: inbox, start_time: start_time, threshold_seconds: 2.hours) + + expect(service.deadline.to_i).to eq(expected_deadline.to_i) + end + end + + context 'when start time is after business hours' do + it 'starts counting from next business day' do + # Wednesday 6:00 PM + 2 hours = Thursday 11:00 AM + start_time = Time.zone.parse('2024-01-17 18:00:00') # Wednesday 6 PM + expected_deadline = Time.zone.parse('2024-01-18 11:00:00') # Thursday 11 AM + + service = described_class.new(inbox: inbox, start_time: start_time, threshold_seconds: 2.hours) + + expect(service.deadline.to_i).to eq(expected_deadline.to_i) + end + end + + context 'when start time is on a closed day' do + it 'starts counting from next business day' do + # Saturday 10:00 AM + 2 hours = Monday 11:00 AM + saturday = Time.zone.parse('2024-01-20 10:00:00') + monday_11am = Time.zone.parse('2024-01-22 11:00:00') + + service = described_class.new(inbox: inbox, start_time: saturday, threshold_seconds: 2.hours) + + expect(service.deadline.to_i).to eq(monday_11am.to_i) + end + end + + context 'when threshold spans multiple days' do + it 'calculates correctly across multiple business days' do + # Monday 4:00 PM + 10 hours = Wednesday 10:00 AM + # Monday: 1h (4-5 PM), Tuesday: 8h (9-5), Wednesday: 1h (9-10 AM) + monday_4pm = Time.zone.parse('2024-01-15 16:00:00') + wednesday_10am = Time.zone.parse('2024-01-17 10:00:00') + + service = described_class.new(inbox: inbox, start_time: monday_4pm, threshold_seconds: 10.hours) + + expect(service.deadline.to_i).to eq(wednesday_10am.to_i) + end + + it 'reuses loaded working hours while calculating across days' do + monday_4pm = Time.zone.parse('2024-01-15 16:00:00') + wednesday_10am = Time.zone.parse('2024-01-17 10:00:00') + working_hours = inbox.working_hours + service = described_class.new(inbox: inbox, start_time: monday_4pm, threshold_seconds: 10.hours) + + expect(working_hours).to receive(:index_by).once.and_call_original + expect(working_hours).not_to receive(:find_by) + + expect(service.deadline.to_i).to eq(wednesday_10am.to_i) + end + end + + context 'with different timezone' do + it 'respects inbox timezone' do + inbox.update!(timezone: 'America/New_York') + # Friday 4:00 PM EST + 2 hours = Monday 10:00 AM EST + friday_4pm_est = Time.zone.parse('2024-01-19 16:00:00 EST') + monday_10am_est = Time.zone.parse('2024-01-22 10:00:00 EST') + + service = described_class.new(inbox: inbox, start_time: friday_4pm_est, threshold_seconds: 2.hours) + + expect(service.deadline.to_i).to eq(monday_10am_est.to_i) + end + end + + context 'when day is open all day' do + it 'treats the day as 24 hours of business time' do + # Set Saturday to open_all_day (0:00 - 23:59) + inbox.working_hours.find_by(day_of_week: 6).update!(open_all_day: true, closed_all_day: false) + + # Saturday 10:00 AM + 2 hours = Saturday 12:00 PM + saturday_10am = Time.zone.parse('2024-01-20 10:00:00') + saturday_12pm = Time.zone.parse('2024-01-20 12:00:00') + + service = described_class.new(inbox: inbox, start_time: saturday_10am, threshold_seconds: 2.hours) + + expect(service.deadline.to_i).to eq(saturday_12pm.to_i) + end + + it 'includes the final minute in the business-time window' do + inbox.working_hours.find_by(day_of_week: 6).update!(open_all_day: true, closed_all_day: false) + + saturday_midnight = Time.zone.parse('2024-01-20 00:00:00') + sunday_midnight = Time.zone.parse('2024-01-21 00:00:00') + + service = described_class.new(inbox: inbox, start_time: saturday_midnight, threshold_seconds: 24.hours) + + expect(service.deadline.to_i).to eq(sunday_midnight.to_i) + end + end + + context 'when days have different business hours' do + it 'uses the correct close time after advancing to next day' do + # Monday (day 1) closes at 17:00, Tuesday (day 2) closes at 20:00 + inbox.working_hours.find_by(day_of_week: 1).update!(open_hour: 9, close_hour: 17) + inbox.working_hours.find_by(day_of_week: 2).update!(open_hour: 9, close_hour: 20) + + # Start at Monday 18:00 (after close) + 10 hours + # Should start counting from Tuesday 9:00 AM + # Tuesday has 11 hours available (9:00-20:00), so 10 hours = Tuesday 19:00 + monday_6pm = Time.zone.parse('2024-01-15 18:00:00') # Monday + tuesday_7pm = Time.zone.parse('2024-01-16 19:00:00') # Tuesday + + service = described_class.new(inbox: inbox, start_time: monday_6pm, threshold_seconds: 10.hours) + + expect(service.deadline.to_i).to eq(tuesday_7pm.to_i) + end + + it 'spans correctly across days with varying hours' do + # Monday (day 1): 9:00-17:00 (8h), Tuesday (day 2): 9:00-20:00 (11h) + inbox.working_hours.find_by(day_of_week: 1).update!(open_hour: 9, close_hour: 17) + inbox.working_hours.find_by(day_of_week: 2).update!(open_hour: 9, close_hour: 20) + + # Start at Monday 16:00 + 12 hours + # Monday: 1h (16:00-17:00), Tuesday: 11h remaining (9:00-20:00) + monday_4pm = Time.zone.parse('2024-01-15 16:00:00') + tuesday_8pm = Time.zone.parse('2024-01-16 20:00:00') + + service = described_class.new(inbox: inbox, start_time: monday_4pm, threshold_seconds: 12.hours) + + expect(service.deadline.to_i).to eq(tuesday_8pm.to_i) + end + end + end +end diff --git a/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb b/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb index 71afd2125..6f672e694 100644 --- a/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb +++ b/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb @@ -19,6 +19,29 @@ RSpec.describe Sla::EvaluateAppliedSlaService do end let!(:applied_sla) { conversation.applied_sla } + describe '#perform - blocked contacts' do + before do + applied_sla.sla_policy.update(first_response_time_threshold: 1.hour, resolution_time_threshold: 1.hour) + conversation.contact.update!(blocked: true) + end + + it 'does not create SLA events or update SLA status' do + described_class.new(applied_sla: applied_sla).perform + + expect(SlaEvent.where(applied_sla: applied_sla)).not_to exist + expect(applied_sla.reload.sla_status).to eq('active') + end + + it 'does not mark resolved conversations as hit or missed' do + conversation.resolved! + + described_class.new(applied_sla: applied_sla).perform + + expect(SlaEvent.where(applied_sla: applied_sla)).not_to exist + expect(applied_sla.reload.sla_status).to eq('active') + end + end + describe '#perform - SLA misses' do context 'when first response SLA is missed' do before { applied_sla.sla_policy.update(first_response_time_threshold: 1.hour) } @@ -140,6 +163,71 @@ RSpec.describe Sla::EvaluateAppliedSlaService do end end + context 'when first response SLA is hit after non-business hours' do + let(:created_at) { Time.zone.parse('2026-06-25 00:39:56 UTC') } + let(:wall_clock_breach_time) { Time.zone.parse('2026-06-25 01:40:03 UTC') } + let(:first_reply_created_at) { Time.zone.parse('2026-06-25 11:45:36 UTC') } + let(:post_reply_eval_time) { Time.zone.parse('2026-06-25 11:46:38 UTC') } + let(:email_inbox) { create(:inbox, :with_email, account: account, working_hours_enabled: true, timezone: 'America/New_York') } + let(:business_hours_sla_policy) do + create( + :sla_policy, + account: account, + first_response_time_threshold: 1.hour, + next_response_time_threshold: nil, + resolution_time_threshold: nil, + only_during_business_hours: true + ) + end + let(:business_hours_conversation) do + create( + :conversation, + account: account, + inbox: email_inbox, + sla_policy: business_hours_sla_policy, + created_at: created_at, + last_activity_at: created_at + ) + end + let(:business_hours_applied_sla) { business_hours_conversation.applied_sla } + + before do + { + 0 => [11, 0, 20, 0], + 1 => [7, 0, 20, 0], + 2 => [7, 0, 20, 0], + 3 => [7, 0, 20, 0], + 4 => [7, 0, 16, 0], + 5 => [7, 0, 16, 0], + 6 => [11, 0, 20, 0] + }.each do |day_of_week, (open_hour, open_minutes, close_hour, close_minutes)| + email_inbox.working_hours.find_by(day_of_week: day_of_week).update!( + open_hour: open_hour, + open_minutes: open_minutes, + close_hour: close_hour, + close_minutes: close_minutes, + closed_all_day: false, + open_all_day: false + ) + end + end + + it 'does not mark FRT missed while outside business hours or after an on-time business-hours reply' do + travel_to wall_clock_breach_time do + described_class.new(applied_sla: business_hours_applied_sla).perform + end + + business_hours_conversation.update!(first_reply_created_at: first_reply_created_at, last_activity_at: first_reply_created_at) + + travel_to post_reply_eval_time do + described_class.new(applied_sla: business_hours_applied_sla).perform + end + + expect(business_hours_applied_sla.reload.sla_status).to eq('active') + expect(SlaEvent.where(applied_sla: business_hours_applied_sla, event_type: 'frt')).not_to exist + end + end + context 'when next response SLA is hit' do before do applied_sla.sla_policy.update(next_response_time_threshold: 6.hours) @@ -191,16 +279,16 @@ RSpec.describe Sla::EvaluateAppliedSlaService do # Simulate conversation timeline # Hit frt # incoming message from customer - create(:message, conversation: conversation, created_at: 6.hours.ago, message_type: :incoming) + create(:message, conversation: conversation, account: conversation.account, created_at: 6.hours.ago, message_type: :incoming) # outgoing message from agent within frt - create(:message, conversation: conversation, created_at: 5.hours.ago, message_type: :outgoing) + create(:message, conversation: conversation, account: conversation.account, created_at: 5.hours.ago, message_type: :outgoing) # Miss nrt first time - create(:message, conversation: conversation, created_at: 4.hours.ago, message_type: :incoming) + create(:message, conversation: conversation, account: conversation.account, created_at: 4.hours.ago, message_type: :incoming) described_class.new(applied_sla: applied_sla).perform # Miss nrt second time - create(:message, conversation: conversation, created_at: 3.hours.ago, message_type: :incoming) + create(:message, conversation: conversation, account: conversation.account, created_at: 3.hours.ago, message_type: :incoming) described_class.new(applied_sla: applied_sla).perform # Conversation is resolved missing rt diff --git a/spec/enterprise/services/voice/outbound_call_builder_spec.rb b/spec/enterprise/services/voice/outbound_call_builder_spec.rb index 796afe715..0dc565eaf 100644 --- a/spec/enterprise/services/voice/outbound_call_builder_spec.rb +++ b/spec/enterprise/services/voice/outbound_call_builder_spec.rb @@ -44,6 +44,54 @@ RSpec.describe Voice::OutboundCallBuilder do end end + it 'assigns the conversation to the agent placing the call' do + call = described_class.perform!( + account: account, + inbox: inbox, + user: user, + contact: contact + ) + + expect(call.conversation.assignee_id).to eq(user.id) + end + + it 'keeps the calling agent assigned even when auto-assignment would pick an online agent' do + other_agent = create(:user, account: account) + create(:inbox_member, inbox: inbox, user: other_agent) + create(:inbox_member, inbox: inbox, user: user) + inbox.update!(enable_auto_assignment: true) + # Only other_agent is online, so round-robin would claim the conversation unless the caller wins at creation. + OnlineStatusTracker.update_presence(account.id, 'User', other_agent.id) + OnlineStatusTracker.set_status(account.id, other_agent.id, 'online') + + call = described_class.perform!( + account: account, + inbox: inbox, + user: user, + contact: contact + ) + + expect(call.conversation.assignee_id).to eq(user.id) + end + + it 'claims a reused conversation for the caller when it is unassigned' do + # Reload so the builder gets a DB-fresh record, mirroring the controller's find_by load. + conversation = create(:conversation, account: account, inbox: inbox, contact: contact).reload + + described_class.perform!(account: account, inbox: inbox, user: user, contact: contact, conversation: conversation) + + expect(conversation.reload.assignee_id).to eq(user.id) + end + + it 'keeps the existing assignee when a reused conversation is already assigned' do + other_agent = create(:user, account: account) + conversation = create(:conversation, account: account, inbox: inbox, contact: contact, assignee: other_agent).reload + + described_class.perform!(account: account, inbox: inbox, user: user, contact: contact, conversation: conversation) + + expect(conversation.reload.assignee_id).to eq(other_agent.id) + end + it 'does not set conversation.identifier or write call state to additional_attributes' do call = described_class.perform!( account: account, diff --git a/spec/enterprise/services/whatsapp/call_service_spec.rb b/spec/enterprise/services/whatsapp/call_service_spec.rb index a17f7572a..4620ca588 100644 --- a/spec/enterprise/services/whatsapp/call_service_spec.rb +++ b/spec/enterprise/services/whatsapp/call_service_spec.rb @@ -57,11 +57,11 @@ describe Whatsapp::CallService do .to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::AlreadyAccepted') } end - it 'raises NotRinging when the call has reached a terminal state' do + it 'raises CallAlreadyEnded when the call has reached a terminal state' do call.update!(status: 'completed') expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept } - .to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::NotRinging') } + .to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::CallAlreadyEnded') } end it 'raises CallFailed when sdp_answer is missing' do @@ -84,13 +84,14 @@ describe Whatsapp::CallService do describe '#reject' do before { allow(provider_service).to receive(:reject_call).and_return(true) } - it 'tells Meta to reject and finalizes the call as failed' do + it 'tells Meta to reject and finalizes the call as rejected' do described_class.new(call: call, agent: agent).reject expect(provider_service).to have_received(:reject_call).with('wacid_abc') - expect(call.reload.status).to eq('failed') + expect(call.reload.status).to eq('rejected') + expect(call.end_reason).to eq('agent_rejected') expect(ActionCable.server).to have_received(:broadcast).with( - "account_#{account.id}", hash_including(event: 'voice_call.ended', data: hash_including(status: 'failed')) + "account_#{account.id}", hash_including(event: 'voice_call.ended', data: hash_including(status: 'rejected')) ) end diff --git a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb index 4651b5f13..a3c5246d2 100644 --- a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb +++ b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb @@ -149,17 +149,39 @@ describe Whatsapp::IncomingCallService do end describe 'terminate with no local row yet' do - it 'logs and skips instead of materialising an inbound missed-call row' do - allow(Rails.logger).to receive(:warn) + # Unique per example: the 60s tombstone isn't rolled back between specs. + let(:tombstone_call_id) { "wacid.#{SecureRandom.hex(6)}" } + + after { Redis::Alfred.delete(format(Redis::Alfred::WHATSAPP_CALL_TERMINATE_TOMBSTONE, call_id: tombstone_call_id)) } + + it 'tombstones the terminate instead of materialising an inbound missed-call row' do allow(ActionCable.server).to receive(:broadcast) params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer') + params[:calls][0][:id] = tombstone_call_id expect { described_class.new(inbox: inbox, params: params).perform } .not_to change(Call, :count) - expect(Rails.logger).to have_received(:warn).with(/Terminate for unknown call/) + key = format(Redis::Alfred::WHATSAPP_CALL_TERMINATE_TOMBSTONE, call_id: tombstone_call_id) + expect(Redis::Alfred.get(key)).to be_present expect(ActionCable.server).not_to have_received(:broadcast) end + + it 'finalizes the call as no_answer when the connect arrives after the tombstone' do + allow(ActionCable.server).to receive(:broadcast) + + terminate = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer') + terminate[:calls][0][:id] = tombstone_call_id + described_class.new(inbox: inbox, params: terminate).perform + + connect = call_payload(event: 'connect', session: { sdp: 'v=0', sdp_type: 'offer' }) + connect[:calls][0][:id] = tombstone_call_id + expect { described_class.new(inbox: inbox, params: connect).perform } + .to change(Call, :count).by(1) + expect(Call.find_by(provider_call_id: tombstone_call_id).status).to eq('no_answer') + expect(ActionCable.server).to have_received(:broadcast) + .with(anything, hash_including(event: 'voice_call.ended')).at_least(:once) + end end describe 'outbound connect with no local row yet' do diff --git a/spec/factories/captain/agent_session.rb b/spec/factories/captain/agent_session.rb new file mode 100644 index 000000000..a7b369b7d --- /dev/null +++ b/spec/factories/captain/agent_session.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :captain_agent_session, class: 'Captain::AgentSession' do + account + association :assistant, factory: :captain_assistant + session_type { :assistant } + subject { create(:conversation, account: account) } + + trait :copilot do + session_type { :copilot } + user + subject { create(:captain_copilot_thread, account: account, user: user) } + end + end +end diff --git a/spec/factories/captain/custom_tool.rb b/spec/factories/captain/custom_tool.rb index 2bfcbf360..d001755b9 100644 --- a/spec/factories/captain/custom_tool.rb +++ b/spec/factories/captain/custom_tool.rb @@ -27,7 +27,7 @@ FactoryBot.define do trait :with_api_key do auth_type { 'api_key' } - auth_config { { key: 'test_api_key', location: 'header', name: 'X-API-Key' } } + auth_config { { key: 'test_api_key', name: 'X-API-Key' } } end trait :with_templates do diff --git a/spec/factories/captain/message_report.rb b/spec/factories/captain/message_report.rb new file mode 100644 index 000000000..d7f1cfca0 --- /dev/null +++ b/spec/factories/captain/message_report.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :captain_message_report, class: 'Captain::MessageReport' do + report_reason { 'incorrect_information' } + description { 'The generated citation is wrong.' } + association :message + association :user + end +end diff --git a/spec/factories/data_import.rb b/spec/factories/data_import.rb index ff7561f31..ad361d237 100644 --- a/spec/factories/data_import.rb +++ b/spec/factories/data_import.rb @@ -3,5 +3,14 @@ FactoryBot.define do data_type { 'contacts' } import_file { Rack::Test::UploadedFile.new(Rails.root.join('spec/assets/contacts.csv'), 'text/csv') } account + + trait :intercom do + data_type { 'intercom' } + source_type { 'api' } + source_provider { 'intercom' } + import_types { %w[contacts conversations] } + access_token { 'intercom-token' } + import_file { nil } + end end end diff --git a/spec/factories/email_template.rb b/spec/factories/email_template.rb index 394eb6590..3830e7e13 100644 --- a/spec/factories/email_template.rb +++ b/spec/factories/email_template.rb @@ -1,5 +1,12 @@ FactoryBot.define do factory :email_template do name { 'MyString' } + body { 'Email template body' } + + trait :layout do + name { EmailTemplate::BRANDED_LAYOUT_NAME } + template_type { :layout } + body { '{{ content_for_layout }}' } + end end end diff --git a/spec/factories/integrations/hooks.rb b/spec/factories/integrations/hooks.rb index e02bc2409..02fcc51e9 100644 --- a/spec/factories/integrations/hooks.rb +++ b/spec/factories/integrations/hooks.rb @@ -14,7 +14,7 @@ FactoryBot.define do trait :dyte do app_id { 'dyte' } - settings { { api_key: 'api_key', organization_id: 'org_id' } } + settings { { account_id: 'account_id', app_id: 'app_id', api_token: 'api_token' } } end trait :google_translate do diff --git a/spec/finders/data_import_error_finder_spec.rb b/spec/finders/data_import_error_finder_spec.rb new file mode 100644 index 000000000..6d546a194 --- /dev/null +++ b/spec/finders/data_import_error_finder_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +RSpec.describe DataImportErrorFinder do + let(:data_import) { create(:data_import, :intercom) } + + it 'returns only the latest five non-skip errors' do + 6.times do |index| + data_import.import_errors.create!( + error_code: 'Intercom::Error', + source_object_id: "error_#{index}", + details: { kind: 'run_error' }, + created_at: Time.zone.at(index) + ) + end + data_import.import_errors.create!( + error_code: 'Intercom::Skipped', + source_object_id: 'skipped_error', + details: { kind: 'skipped' }, + created_at: Time.zone.at(10) + ) + + errors = described_class.new(data_import).import_errors + + expect(errors.pluck(:source_object_id)).to eq(%w[error_5 error_4 error_3 error_2 error_1]) + end +end diff --git a/spec/finders/data_import_skip_log_finder_spec.rb b/spec/finders/data_import_skip_log_finder_spec.rb new file mode 100644 index 000000000..473f869f0 --- /dev/null +++ b/spec/finders/data_import_skip_log_finder_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe DataImportSkipLogFinder do + let(:data_import) { create(:data_import, :intercom) } + + before do + 6.times do |index| + data_import.import_errors.create!( + error_code: 'Intercom::Skipped', + source_object_type: 'message', + source_object_id: "message_#{index}", + details: { kind: 'skipped' }, + created_at: Time.zone.at(index) + ) + end + data_import.import_errors.create!( + error_code: 'Intercom::Skipped', + source_object_type: 'contact', + source_object_id: 'contact_1', + details: { kind: 'skipped' } + ) + end + + it 'filters skip logs and returns only the latest five', :aggregate_failures do + finder = described_class.new(data_import, skip_logs_type: 'message') + + expect(finder.skip_logs.pluck(:source_object_id)).to eq(%w[message_5 message_4 message_3 message_2 message_1]) + expect(finder.selected_source_object_type).to eq('message') + expect(finder.counts_by_type).to include('message' => 6, 'contact' => 1) + end + + it 'ignores unsupported source object filters' do + finder = described_class.new(data_import, skip_logs_type: 'company') + + expect(finder.selected_source_object_type).to be_nil + expect(finder.skip_logs.size).to eq(5) + end +end diff --git a/spec/jobs/agents/destroy_job_spec.rb b/spec/jobs/agents/destroy_job_spec.rb index cf6bb2ffa..7c1c16e0c 100644 --- a/spec/jobs/agents/destroy_job_spec.rb +++ b/spec/jobs/agents/destroy_job_spec.rb @@ -7,6 +7,7 @@ RSpec.describe Agents::DestroyJob do let(:user) { create(:user, account: account) } let(:team1) { create(:team, account: account) } let!(:inbox) { create(:inbox, account: account) } + let(:store) { Conversations::UnreadCounts::FilteredCountStore } before do create(:team_member, team: team1, user: user) @@ -30,5 +31,13 @@ RSpec.describe Agents::DestroyJob do expect(user.notification_settings.length).to eq 0 expect(user.assigned_conversations.where(account: account).length).to eq 0 end + + it 'invalidates saved filter snapshots when assigned conversations are unassigned' do + account.enable_features!(:unread_count_for_filters) + + expect do + described_class.perform_now(account, user) + end.to change { store.conversation_version(account.id) }.by(1) + end end end diff --git a/spec/jobs/channels/whatsapp/templates_sync_scheduler_job_spec.rb b/spec/jobs/channels/whatsapp/templates_sync_scheduler_job_spec.rb index cf0b30842..bd47afc23 100644 --- a/spec/jobs/channels/whatsapp/templates_sync_scheduler_job_spec.rb +++ b/spec/jobs/channels/whatsapp/templates_sync_scheduler_job_spec.rb @@ -24,6 +24,23 @@ RSpec.describe Channels::Whatsapp::TemplatesSyncSchedulerJob do ) end + it 'does not schedule templates_sync_jobs for suspended accounts' do + stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook') + suspended_account = create(:account, status: :suspended) + suspended_channel = create( + :channel_whatsapp, + account: suspended_account, + sync_templates: false, + message_templates_last_updated: nil + ) + + described_class.perform_now + + expect(Channels::Whatsapp::TemplatesSyncJob).not_to( + have_been_enqueued.with(suspended_channel).on_queue('low') + ) + end + it 'schedules templates_sync_job for oldest synced channels first' do stub_const('Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT', 2) stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook') diff --git a/spec/jobs/data_imports/intercom/import_jobs_spec.rb b/spec/jobs/data_imports/intercom/import_jobs_spec.rb new file mode 100644 index 000000000..52b72e59d --- /dev/null +++ b/spec/jobs/data_imports/intercom/import_jobs_spec.rb @@ -0,0 +1,218 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::ImportJob do + let(:account) { create(:account) } + let(:data_import) do + create( + :data_import, :intercom, + account: account, + import_types: %w[contacts conversations] + ) + end + let(:importer) { instance_double(DataImports::Intercom::Importer) } + let(:run_id) { 'intercom-run-1' } + + before do + account.enable_features!('data_import') + data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => run_id }) + allow(DataImports::Intercom::Importer).to receive(:new).with(data_import: data_import, run_id: run_id).and_return(importer) + end + + describe DataImports::Intercom::BaseJob do + it 'checks rate limit retry before the generic client retry' do + expect(described_class.rescue_handlers.last.first).to eq('DataImports::Intercom::Client::RateLimitError') + end + end + + describe DataImports::Intercom::ImportJob do + it 'starts the import and enqueues the first contacts page' do + allow(importer).to receive_messages(start!: true, import_contacts?: true, contacts_completed?: false, cursor_for: 'contact-cursor') + + expect do + described_class.perform_now(data_import, run_id) + end.to have_enqueued_job(DataImports::Intercom::ContactsPageJob).with(data_import, 'contact-cursor', run_id).on_queue('low') + + expect(importer).to have_received(:start!) + end + + it 'resumes at conversations when contacts are already completed' do + allow(importer).to receive_messages( + start!: true, + import_contacts?: true, + contacts_completed?: true, + import_conversations?: true, + conversations_completed?: false + ) + allow(importer).to receive(:cursor_for).with('conversations').and_return('conversation-cursor') + + expect do + described_class.perform_now(data_import, run_id) + end.to have_enqueued_job(DataImports::Intercom::ConversationsPageJob).with(data_import, 'conversation-cursor', run_id) + end + + it 'finishes immediately when every requested stage is already complete' do + allow(importer).to receive_messages( + start!: true, + import_contacts?: true, + contacts_completed?: true, + import_conversations?: true, + conversations_completed?: true, + finish!: true + ) + + expect do + described_class.perform_now(data_import, run_id) + end.not_to have_enqueued_job + + expect(importer).to have_received(:finish!) + end + + it 'skips stale import jobs from an earlier run' do + data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' }) + + expect(DataImports::Intercom::Importer).not_to receive(:new) + + described_class.perform_now(data_import, 'old-run') + end + end + + describe DataImports::Intercom::ContactsPageJob do + it 'hands off to conversations when a retry finds contacts already completed' do + allow(importer).to receive_messages( + contacts_completed?: true, + import_conversations?: true, + conversations_completed?: false + ) + allow(importer).to receive(:cursor_for).with('conversations').and_return('conversation-cursor') + expect(importer).not_to receive(:import_contacts_page) + + expect do + described_class.perform_now(data_import, 'completed-contact-cursor', run_id) + end.to have_enqueued_job(DataImports::Intercom::ConversationsPageJob).with(data_import, 'conversation-cursor', run_id) + end + + it 'imports one contacts page and enqueues the next contacts page' do + result = DataImports::Intercom::Importer::PageResult.new(next_cursor: 'next-contact-cursor') + allow(importer).to receive_messages(contacts_completed?: false) + allow(importer).to receive(:import_contacts_page).with(starting_after: 'current-contact-cursor').and_return(result) + + expect do + described_class.perform_now(data_import, 'current-contact-cursor', run_id) + end.to have_enqueued_job(described_class).with(data_import, 'next-contact-cursor', run_id) + end + + it 'hands off to conversations after the final contacts page' do + result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil) + allow(importer).to receive_messages( + contacts_completed?: false, + import_conversations?: true, + conversations_completed?: false + ) + allow(importer).to receive(:import_contacts_page).with(starting_after: nil).and_return(result) + allow(importer).to receive(:cursor_for).with('conversations').and_return(nil) + + expect do + described_class.perform_now(data_import, nil, run_id) + end.to have_enqueued_job(DataImports::Intercom::ConversationsPageJob).with(data_import, nil, run_id) + end + + it 'finishes after the final contacts page when conversations are not requested' do + result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil) + allow(importer).to receive_messages(contacts_completed?: false, import_conversations?: false, finish!: true) + allow(importer).to receive(:import_contacts_page).with(starting_after: nil).and_return(result) + + expect do + described_class.perform_now(data_import, nil, run_id) + end.not_to have_enqueued_job + + expect(importer).to have_received(:finish!) + end + + it 'skips stale page jobs from an earlier run' do + data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' }) + + expect(DataImports::Intercom::Importer).not_to receive(:new) + + described_class.perform_now(data_import, 'current-contact-cursor', 'old-run') + end + + it 'skips failed page jobs from backend retries' do + data_import.update!(status: :failed) + + expect(DataImports::Intercom::Importer).not_to receive(:new) + + described_class.perform_now(data_import, 'current-contact-cursor', run_id) + end + + it 'does not enqueue another stage when the page import becomes stale' do + result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil) + allow(importer).to receive_messages(contacts_completed?: false, finish!: true) + allow(importer).to receive(:import_contacts_page).with(starting_after: 'current-contact-cursor') do + data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' }) + result + end + + expect do + described_class.perform_now(data_import, 'current-contact-cursor', run_id) + end.not_to have_enqueued_job + + expect(importer).not_to have_received(:finish!) + end + end + + describe DataImports::Intercom::ConversationsPageJob do + it 'finishes when a retry finds conversations already completed' do + allow(importer).to receive_messages(conversations_completed?: true, finish!: true) + expect(importer).not_to receive(:import_conversations_page) + + described_class.perform_now(data_import, 'completed-conversation-cursor', run_id) + + expect(importer).to have_received(:finish!) + end + + it 'imports one conversations page and enqueues the next conversations page' do + result = DataImports::Intercom::Importer::PageResult.new(next_cursor: 'next-conversation-cursor') + allow(importer).to receive_messages(conversations_completed?: false) + allow(importer).to receive(:import_conversations_page).with(starting_after: 'current-conversation-cursor').and_return(result) + + expect do + described_class.perform_now(data_import, 'current-conversation-cursor', run_id) + end.to have_enqueued_job(described_class).with(data_import, 'next-conversation-cursor', run_id) + end + + it 'finishes after the final conversations page' do + result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil) + allow(importer).to receive_messages(conversations_completed?: false, finish!: true) + allow(importer).to receive(:import_conversations_page).with(starting_after: nil).and_return(result) + + expect do + described_class.perform_now(data_import, nil, run_id) + end.not_to have_enqueued_job + + expect(importer).to have_received(:finish!) + end + + it 'skips stale page jobs from an earlier run' do + data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' }) + + expect(DataImports::Intercom::Importer).not_to receive(:new) + + described_class.perform_now(data_import, 'current-conversation-cursor', 'old-run') + end + + it 'does not finish when the page import becomes stale' do + result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil) + allow(importer).to receive_messages(conversations_completed?: false, finish!: true) + allow(importer).to receive(:import_conversations_page).with(starting_after: 'current-conversation-cursor') do + data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' }) + result + end + + expect do + described_class.perform_now(data_import, 'current-conversation-cursor', run_id) + end.not_to have_enqueued_job + + expect(importer).not_to have_received(:finish!) + end + end +end diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb index 34c889967..2cb24ce04 100644 --- a/spec/lib/captain/base_task_service_spec.rb +++ b/spec/lib/captain/base_task_service_spec.rb @@ -21,6 +21,7 @@ RSpec.describe Captain::BaseTaskService do let(:service) { test_service_class.new(account: account, conversation_display_id: conversation.display_id) } before do + InstallationConfig.where(name: 'CAPTAIN_OPEN_AI_API_KEY').destroy_all create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') # Stub captain enabled check to allow OSS specs to test base functionality # without enterprise module interference @@ -167,6 +168,37 @@ RSpec.describe Captain::BaseTaskService do service.send(:make_api_call, model: model, messages: messages) end + it 'uses the resolved feature model for the request and instrumentation' do + account.update!(captain_models: { 'editor' => 'gpt-4.1' }) + + expect(mock_context).to receive(:chat).with(model: 'gpt-4.1').and_return(mock_chat) + expect(service).to receive(:instrument_llm_call).with( + hash_including(model: 'gpt-4.1', feature_name: 'test_event') + ).and_call_original + + service.send(:make_api_call, feature: 'editor', messages: messages) + end + + it 'uses the supplied model as a feature fallback when there is no account override' do + expect(mock_context).to receive(:chat).with(model: 'gpt-5.2').and_return(mock_chat) + + service.send(:make_api_call, feature: 'document_faq_generation', model: 'gpt-5.2', messages: messages) + end + + it 'uses the help center article generation feature default' do + expect(mock_context).to receive(:chat).with(model: 'gpt-5.2').and_return(mock_chat) + + service.send(:make_api_call, feature: 'help_center_article_generation', messages: messages) + end + + it 'prefers account overrides over supplied feature fallback models' do + account.update!(captain_models: { 'help_center_article_generation' => 'gpt-4.1' }) + + expect(mock_context).to receive(:chat).with(model: 'gpt-4.1').and_return(mock_chat) + + service.send(:make_api_call, feature: 'help_center_article_generation', model: 'gpt-5.2', messages: messages) + end + it 'returns formatted response with tokens' do result = service.send(:make_api_call, model: model, messages: messages) @@ -353,7 +385,10 @@ RSpec.describe Captain::BaseTaskService do describe '#prompt_from_file' do it 'reads prompt from file' do - allow(Rails.root).to receive(:join).and_return(instance_double(Pathname, read: 'Test prompt content')) + service + prompt_path = instance_double(Pathname, read: 'Test prompt content') + allow(Rails.root).to receive(:join).with('lib/integrations/openai/openai_prompts', 'test.liquid').and_return(prompt_path) + expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content') end end diff --git a/spec/lib/captain/csat_utility_analysis_service_spec.rb b/spec/lib/captain/csat_utility_analysis_service_spec.rb index 34e0c9ece..70c07cdb2 100644 --- a/spec/lib/captain/csat_utility_analysis_service_spec.rb +++ b/spec/lib/captain/csat_utility_analysis_service_spec.rb @@ -25,6 +25,14 @@ RSpec.describe Captain::CsatUtilityAnalysisService do expect(result[:optimized_message]).to eq('Utility-safe message') expect(result[:message]).to eq('{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}') end + + it 'routes through the editor feature' do + expect(service).to receive(:make_api_call).with( + hash_including(feature: 'editor') + ).and_return({ message: '{"classification":"LIKELY_UTILITY"}' }) + + service.perform + end end describe '#api_key' do diff --git a/spec/lib/captain/follow_up_service_spec.rb b/spec/lib/captain/follow_up_service_spec.rb index 9e330efdc..45535d574 100644 --- a/spec/lib/captain/follow_up_service_spec.rb +++ b/spec/lib/captain/follow_up_service_spec.rb @@ -42,6 +42,7 @@ RSpec.describe Captain::FollowUpService do context 'when follow-up context exists' do it 'constructs messages array with full conversation history' do expect(service).to receive(:make_api_call) do |args| + expect(args[:feature]).to eq('editor') messages = args[:messages] expect(messages).to match( diff --git a/spec/lib/captain/label_suggestion_service_spec.rb b/spec/lib/captain/label_suggestion_service_spec.rb index 0c40b103c..c8d9ed6c7 100644 --- a/spec/lib/captain/label_suggestion_service_spec.rb +++ b/spec/lib/captain/label_suggestion_service_spec.rb @@ -58,6 +58,7 @@ RSpec.describe Captain::LabelSuggestionService do it 'builds labels_with_messages format correctly' do expect(service).to receive(:make_api_call) do |args| + expect(args[:feature]).to eq('label_suggestion') user_message = args[:messages].find { |m| m[:role] == 'user' }[:content] expect(user_message).to include('Messages:') diff --git a/spec/lib/captain/reply_suggestion_service_spec.rb b/spec/lib/captain/reply_suggestion_service_spec.rb index a53825ee4..9b5cbfa9e 100644 --- a/spec/lib/captain/reply_suggestion_service_spec.rb +++ b/spec/lib/captain/reply_suggestion_service_spec.rb @@ -12,6 +12,7 @@ RSpec.describe Captain::ReplySuggestionService do before do create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') create(:message, conversation: conversation, message_type: :incoming, content: 'I need help') + allow(account).to receive(:feature_enabled?).and_call_original allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true) mock_response = instance_double(RubyLLM::Message, content: 'Sure, I can help!', input_tokens: 50, output_tokens: 20) @@ -30,6 +31,12 @@ RSpec.describe Captain::ReplySuggestionService do end describe '#perform' do + it 'routes through the editor feature' do + expect(Llm::FeatureRouter).to receive(:resolve).with(feature: 'editor', account: account).and_call_original + + service.perform + end + it 'returns the suggested reply' do result = service.perform diff --git a/spec/lib/captain/rewrite_service_spec.rb b/spec/lib/captain/rewrite_service_spec.rb index 3c1d7997a..e4ef7efbf 100644 --- a/spec/lib/captain/rewrite_service_spec.rb +++ b/spec/lib/captain/rewrite_service_spec.rb @@ -29,6 +29,7 @@ RSpec.describe Captain::RewriteService do expect(service).to receive(:prompt_from_file).with('fix_spelling_grammar').and_return('Fix errors') expect(service).to receive(:make_api_call) do |args| + expect(args[:feature]).to eq('editor') expect(args[:messages][0][:content]).to eq('Fix errors') expect(args[:messages][1][:content]).to eq(content) { message: 'Fixed' } @@ -122,6 +123,7 @@ RSpec.describe Captain::RewriteService do it 'uses conversation context and draft message with Liquid template' do expect(service).to receive(:make_api_call) do |args| + expect(args[:feature]).to eq('editor') system_content = args[:messages][0][:content] expect(system_content).to include('Context:') diff --git a/spec/lib/captain/summary_service_spec.rb b/spec/lib/captain/summary_service_spec.rb index c5ec50687..def6daefe 100644 --- a/spec/lib/captain/summary_service_spec.rb +++ b/spec/lib/captain/summary_service_spec.rb @@ -21,9 +21,9 @@ RSpec.describe Captain::SummaryService do end describe '#perform' do - it 'passes correct model to API' do + it 'routes through the editor feature' do expect(service).to receive(:make_api_call).with( - hash_including(model: Captain::BaseTaskService::GPT_MODEL) + hash_including(feature: 'editor') ).and_call_original service.perform diff --git a/spec/lib/config_loader_spec.rb b/spec/lib/config_loader_spec.rb index 60b689872..4c5fd4fbd 100644 --- a/spec/lib/config_loader_spec.rb +++ b/spec/lib/config_loader_spec.rb @@ -42,5 +42,27 @@ describe ConfigLoader do expect(InstallationConfig.find_by(name: 'WHO').value).to eq('covid 19') end end + + it 'preserves feature flag column metadata in account level defaults' do + Dir.mktmpdir do |config_path| + File.write("#{config_path}/installation_config.yml", <<~YAML) + - name: TEST_CONFIG + value: test + locked: true + YAML + File.write("#{config_path}/features.yml", <<~YAML) + - name: extension_feature + display_name: Extension Feature + enabled: false + column: feature_flags_ext_1 + YAML + + described_class.new.process(config_path: config_path) + + expect(InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').value).to include( + a_hash_including('name' => 'extension_feature', 'column' => 'feature_flags_ext_1') + ) + end + end end end diff --git a/spec/lib/dyte_spec.rb b/spec/lib/dyte_spec.rb index 0963bfffe..2dc16ba49 100644 --- a/spec/lib/dyte_spec.rb +++ b/spec/lib/dyte_spec.rb @@ -1,17 +1,17 @@ require 'rails_helper' describe Dyte do - let(:dyte_client) { described_class.new('org_id', 'api_key') } + let(:dyte_client) { described_class.new('account_id', 'app_id', 'api_token') } let(:headers) { { 'Content-Type' => 'application/json' } } - it 'raises an exception if api_key or organization ID is absent' do + it 'raises an exception if account ID, app ID, or API token is absent' do expect { described_class.new }.to raise_error(StandardError) end context 'when create_a_meeting is called' do context 'when API response is success' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings') .to_return( status: 200, body: { success: true, data: { id: 'meeting_id' } }.to_json, @@ -27,7 +27,7 @@ describe Dyte do context 'when API response is invalid' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings') .to_return(status: 422, body: { message: 'Title is required' }.to_json, headers: headers) end @@ -36,9 +36,23 @@ describe Dyte do expect(response).to eq({ error: { 'message' => 'Title is required' }, error_code: 422 }) end end + + context 'when API response succeeds without data' do + before do + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings') + .to_return(status: 200, body: { success: true, data: nil }.to_json, headers: headers) + end + + it 'returns an explicit unexpected response error' do + response = dyte_client.create_a_meeting('title_of_the_meeting') + expect(response).to eq({ error: :unexpected_response, error_code: 200 }) + end + end end context 'when add_participant_to_meeting is called' do + let(:participants_url) { 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants' } + context 'when API parameters are missing' do it 'raises an exception' do expect { dyte_client.add_participant_to_meeting }.to raise_error(StandardError) @@ -47,23 +61,26 @@ describe Dyte do context 'when API response is success' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants') + stub_request(:post, participants_url) .to_return( status: 200, - body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json, + body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json, headers: headers ) end it 'returns api response' do response = dyte_client.add_participant_to_meeting('m_id', 'c_id', 'name', 'https://avatar.url') - expect(response).to eq({ 'id' => 'random_uuid', 'auth_token' => 'json-web-token' }) + expect(response).to eq({ 'id' => 'random_uuid', 'token' => 'json-web-token' }) + expect(WebMock).to( + have_requested(:post, participants_url).with { |request| JSON.parse(request.body)['preset_name'] == 'group-call-host' } + ) end end context 'when API response is invalid' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants') + stub_request(:post, participants_url) .to_return(status: 422, body: { message: 'Meeting ID is invalid' }.to_json, headers: headers) end @@ -72,5 +89,83 @@ describe Dyte do expect(response).to eq({ error: { 'message' => 'Meeting ID is invalid' }, error_code: 422 }) end end + + context 'when the default preset is not found' do + before do + stub_request(:post, participants_url) + .with { |request| JSON.parse(request.body)['preset_name'] == 'group-call-host' } + .to_return( + status: 404, + body: { success: false, error: { code: 404, message: 'ResourceNotFound: No preset found with name group-call-host' } }.to_json, + headers: headers + ) + + stub_request(:post, participants_url) + .with { |request| JSON.parse(request.body)['preset_name'] == 'group_call_host' } + .to_return( + status: 200, + body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json, + headers: headers + ) + end + + it 'retries with the legacy Dyte preset name' do + response = dyte_client.add_participant_to_meeting('m_id', 'c_id', 'name', 'https://avatar.url') + + expect(response).to eq({ 'id' => 'random_uuid', 'token' => 'json-web-token' }) + end + end + end + + context 'when refresh_participant_token is called' do + let(:participant_token_url) do + 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token' + end + + context 'when API response is success' do + before do + stub_request(:post, participant_token_url) + .to_return(status: 200, body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json, headers: headers) + end + + it 'returns a refreshed participant token' do + response = dyte_client.refresh_participant_token('m_id', 'participant_id') + + expect(response).to eq({ 'token' => 'refreshed-json-web-token' }) + end + end + + context 'when API parameters are missing' do + it 'raises an exception' do + expect { dyte_client.refresh_participant_token('m_id', nil) }.to raise_error(StandardError) + end + end + end + + context 'when fetch_participants is called' do + let(:participants_url) { 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants' } + + context 'when API response is success' do + before do + stub_request(:get, participants_url) + .to_return( + status: 200, + body: { success: true, data: [{ id: 'participant_id', custom_participant_id: 'c_id' }] }.to_json, + headers: headers + ) + end + + it 'returns participants' do + response = dyte_client.fetch_participants('m_id') + + expect(response).to eq([{ 'id' => 'participant_id', 'custom_participant_id' => 'c_id' }]) + end + end + + context 'when API parameters are missing' do + it 'raises an exception' do + expect { dyte_client.fetch_participants(nil) }.to raise_error(StandardError) + end + end end end diff --git a/spec/lib/email_templates/db_resolver_service_spec.rb b/spec/lib/email_templates/db_resolver_service_spec.rb index 56c8a648b..cc8ddd0b2 100644 --- a/spec/lib/email_templates/db_resolver_service_spec.rb +++ b/spec/lib/email_templates/db_resolver_service_spec.rb @@ -4,6 +4,10 @@ describe EmailTemplates::DbResolverService do subject(:resolver) { described_class.using(EmailTemplate, {}) } describe '#find_templates' do + after do + Current.reset + end + context 'when template does not exist in db' do it 'return empty array' do expect(resolver.find_templates('test', '', false, [])).to eq([]) @@ -53,7 +57,6 @@ describe EmailTemplates::DbResolverService do "DB Template - #{account_template.id}", handler, **template_details ).inspect ) - Current.account = nil end it 'return installation template when current account dont have template' do @@ -73,7 +76,69 @@ describe EmailTemplates::DbResolverService do "DB Template - #{installation_template.id}", handler, **template_details ).inspect ) - Current.account = nil + end + end + + context 'when inbox template exists in db' do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, :with_email, account: account) } + let!(:inbox_template) { create(:email_template, :layout, account: account, inbox: inbox, body: 'inbox {{ content_for_layout }}') } + let!(:installation_template) { create(:email_template, :layout, body: 'global {{ content_for_layout }}') } + + it 'returns inbox template when branded email templates feature is enabled' do + account.enable_features!(:branded_email_templates) + Current.account = account + Current.inbox = inbox + + expect(resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first.source).to eq(inbox_template.body) + end + + it 'skips account template when branded email templates feature is disabled' do + account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}') + Current.account = account + Current.inbox = inbox + + resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first + expect(resolved_template.source).to eq(installation_template.body) + expect(resolved_template.source).not_to eq(account_template.body) + end + + it 'returns account template when current inbox is not email and feature is enabled' do + account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}') + account.enable_features!(:branded_email_templates) + Current.account = account + Current.inbox = create(:inbox, account: account) + + resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first + expect(resolved_template.source).to eq(account_template.body) + expect(resolved_template.source).not_to eq(installation_template.body) + end + + it 'skips account template when current inbox is not email and feature is disabled' do + account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}') + Current.account = account + Current.inbox = create(:inbox, account: account) + + resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first + expect(resolved_template.source).to eq(installation_template.body) + expect(resolved_template.source).not_to eq(account_template.body) + end + + it 'skips account template without an inbox when feature is disabled' do + account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}') + Current.account = account + + resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first + expect(resolved_template.source).to eq(installation_template.body) + expect(resolved_template.source).not_to eq(account_template.body) + end + + it 'falls back to english when requested locale does not have a template' do + account.enable_features!(:branded_email_templates) + Current.account = account + Current.inbox = inbox + + expect(resolver.find_templates('base', 'layouts/mailer', false, { locale: [:fr] }).first.source).to eq(inbox_template.body) end end end diff --git a/spec/lib/integrations/cloudflare/realtime_kit_credentials_validator_spec.rb b/spec/lib/integrations/cloudflare/realtime_kit_credentials_validator_spec.rb new file mode 100644 index 000000000..3eeee4665 --- /dev/null +++ b/spec/lib/integrations/cloudflare/realtime_kit_credentials_validator_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +RSpec.describe Integrations::Cloudflare::RealtimeKitCredentialsValidator do + let(:account_id) { 'account_id' } + let(:app_id) { 'app_id' } + let(:api_token) { 'api_token' } + let(:token_verify_url) { 'https://api.cloudflare.com/client/v4/user/tokens/verify' } + let(:apps_url) { "https://api.cloudflare.com/client/v4/accounts/#{account_id}/realtime/kit/apps" } + let(:apps_page_size) { described_class::APPS_PAGE_SIZE } + + it 'accepts an active token with access to the requested RealtimeKit app' do + stub_token_verify(status: 'active') + stub_apps_list([{ id: app_id }]) + + expect(described_class.valid?(account_id, app_id, api_token)).to be true + expect(described_class.validate(account_id, app_id, api_token).success?).to be true + end + + it 'rejects inactive tokens' do + stub_token_verify(status: 'disabled') + + expect(described_class.valid?(account_id, app_id, api_token)).to be false + expect(described_class.validate(account_id, app_id, api_token).error).to eq(:invalid_api_token) + end + + it 'rejects tokens without access to the Cloudflare account' do + stub_token_verify(status: 'active') + stub_apps_request.to_return(status: 403, body: { success: false }.to_json) + + expect(described_class.valid?(account_id, app_id, api_token)).to be false + expect(described_class.validate(account_id, app_id, api_token).error).to eq(:invalid_account_or_permissions) + end + + it 'rejects a RealtimeKit App ID that is not present in the account' do + stub_token_verify(status: 'active') + stub_apps_list([{ id: 'another_app_id' }]) + + expect(described_class.valid?(account_id, app_id, api_token)).to be false + expect(described_class.validate(account_id, app_id, api_token).error).to eq(:app_not_found) + end + + it 'accepts a RealtimeKit App ID from a later apps page' do + stub_const("#{described_class}::APPS_PAGE_SIZE", 1) + stub_token_verify(status: 'active') + stub_apps_list([{ id: 'another_app_id' }], page_no: 1, total_count: 2) + stub_apps_list([{ id: app_id }], page_no: 2, total_count: 2) + + expect(described_class.validate(account_id, app_id, api_token).success?).to be true + end + + it 'rejects blank credentials without making a network call' do + expect(described_class.valid?(nil, app_id, api_token)).to be false + expect(described_class.valid?(account_id, nil, api_token)).to be false + expect(described_class.valid?(account_id, app_id, nil)).to be false + expect(described_class.validate(nil, app_id, api_token).error).to eq(:missing_credentials) + end + + it 'rejects transient Cloudflare failures instead of saving unverified credentials' do + stub_request(:get, token_verify_url).to_return(status: 500) + stub_apps_list([{ id: app_id }]) + expect(described_class.validate(account_id, app_id, api_token).error).to eq(:verification_failed) + + stub_token_verify(status: 'active') + stub_apps_request.to_return(status: 500) + expect(described_class.validate(account_id, app_id, api_token).error).to eq(:verification_failed) + end + + it 'rejects credentials when Cloudflare cannot be reached' do + stub_request(:get, token_verify_url).to_raise(Faraday::TimeoutError) + + expect(described_class.validate(account_id, app_id, api_token).error).to eq(:verification_failed) + end + + def stub_token_verify(status:) + stub_request(:get, token_verify_url) + .with(headers: { 'Authorization' => "Bearer #{api_token}" }) + .to_return(status: 200, body: { success: true, result: { status: status } }.to_json) + end + + def stub_apps_list(apps, page_no: 1, total_count: apps.size) + stub_apps_request(page_no: page_no) + .to_return(status: 200, body: apps_response_body(apps, total_count: total_count).to_json) + end + + def stub_apps_request(page_no: 1) + stub_request(:get, apps_url) + .with( + headers: { 'Authorization' => "Bearer #{api_token}" }, + query: { page_no: page_no.to_s, per_page: apps_page_size.to_s } + ) + end + + def apps_response_body(apps, total_count: apps.size) + { success: true, data: apps.map(&:stringify_keys), paging: { total_count: total_count } } + end +end diff --git a/spec/lib/integrations/dyte/processor_service_spec.rb b/spec/lib/integrations/dyte/processor_service_spec.rb index e914ce4cf..5294c4c0d 100644 --- a/spec/lib/integrations/dyte/processor_service_spec.rb +++ b/spec/lib/integrations/dyte/processor_service_spec.rb @@ -7,15 +7,26 @@ describe Integrations::Dyte::ProcessorService do let(:conversation) { create(:conversation, account: account, status: :pending) } let(:processor) { described_class.new(account: account, conversation: conversation) } let(:agent) { create(:user, account: account, role: :agent) } + let(:dyte_settings) { { account_id: 'account_id', app_id: 'app_id', api_token: 'api_token' } } + let(:integration_message) do + create(:message, content_type: 'integrations', + content_attributes: { type: 'dyte', data: { meeting_id: 'm_id' } }, + conversation: conversation) + end before do - create(:integrations_hook, :dyte, account: account) + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(true, nil)) + + hook = build(:integrations_hook, :dyte, account: account, settings: dyte_settings) + hook.save!(validate: false) if dyte_settings[:organization_id].present? + hook.save! unless hook.persisted? end describe '#create_a_meeting' do context 'when the API response is success' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings') .to_return( status: 200, body: { success: true, data: { id: 'meeting_id' } }.to_json, @@ -32,7 +43,7 @@ describe Integrations::Dyte::ProcessorService do context 'when the API response is errored' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings') .to_return( status: 422, body: { success: false, data: { message: 'Title is required' } }.to_json, @@ -46,15 +57,28 @@ describe Integrations::Dyte::ProcessorService do expect(conversation.reload.messages.count).to eq(0) end end + + context 'when the stored hook still has legacy Dyte credentials' do + let(:dyte_settings) { { organization_id: 'org_id', api_key: 'dyte_api_key' } } + + it 'returns a normal error response without creating a RealtimeKit client' do + expect(Dyte).not_to receive(:new) + + response = processor.create_a_meeting(agent) + + expect(response).to eq({ error: I18n.t('errors.dyte.realtimekit_credentials_required') }) + expect(conversation.reload.messages.count).to eq(0) + end + end end describe '#add_participant_to_meeting' do context 'when the API response is success' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants') .to_return( status: 200, - body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json, + body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json, headers: headers ) end @@ -63,6 +87,117 @@ describe Integrations::Dyte::ProcessorService do response = processor.add_participant_to_meeting('m_id', agent) expect(response).not_to be_nil end + + it 'stores the RealtimeKit participant ID on the integration message' do + response = processor.add_participant_to_meeting('m_id', agent, integration_message) + + expect(response).not_to be_nil + expect(integration_message.reload.content_attributes.dig('data', 'participants', "User:#{agent.id}")).to eq('random_uuid') + end + + it 'sends a namespaced participant ID to RealtimeKit' do + processor.add_participant_to_meeting('m_id', agent, integration_message) + + expect(WebMock).to( + have_requested(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants') + .with { |request| JSON.parse(request.body)['custom_participant_id'] == "User:#{agent.id}" } + ) + end + end + + context 'when the participant ID is already stored on the integration message' do + let(:integration_message) do + create(:message, content_type: 'integrations', + content_attributes: { type: 'dyte', data: { meeting_id: 'm_id', participants: { "User:#{agent.id}" => 'participant_id' } } }, + conversation: conversation) + end + + before do + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token') + .to_return( + status: 200, + body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json, + headers: headers + ) + end + + it 'returns a refreshed participant token without creating the participant again' do + response = processor.add_participant_to_meeting('m_id', agent, integration_message) + + expect(response).to eq({ 'token' => 'refreshed-json-web-token' }) + expect(WebMock).not_to have_requested( + :post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants' + ) + end + end + + context 'when the participant exists in RealtimeKit but is not stored on the integration message' do + before do + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants') + .to_return( + status: 422, + body: { success: false, error: 'Participant already exists' }.to_json, + headers: headers + ) + stub_request(:get, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants') + .to_return( + status: 200, + body: { success: true, data: [{ id: 'participant_id', custom_participant_id: "User:#{agent.id}" }] }.to_json, + headers: headers + ) + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token') + .to_return( + status: 200, + body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json, + headers: headers + ) + end + + it 'finds the existing participant and stores the RealtimeKit participant ID' do + response = processor.add_participant_to_meeting('m_id', agent, integration_message) + + expect(response).to eq({ 'token' => 'refreshed-json-web-token' }) + expect(integration_message.reload.content_attributes.dig('data', 'participants', "User:#{agent.id}")).to eq('participant_id') + end + end + + context 'when a contact and agent have the same database ID' do + let(:contact) { create(:contact, account: account) } + + before do + allow(contact).to receive(:id).and_return(agent.id) + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants') + .to_return( + status: 200, + body: { success: true, data: { id: 'contact_participant_id', token: 'json-web-token' } }.to_json, + headers: headers + ) + end + + it 'stores the contact participant separately from the agent participant' do + integration_message.update!( + content_attributes: { type: 'dyte', data: { meeting_id: 'm_id', participants: { "User:#{agent.id}" => 'agent_participant_id' } } } + ) + + response = processor.add_participant_to_meeting('m_id', contact, integration_message) + + expect(response).to eq({ 'id' => 'contact_participant_id', 'token' => 'json-web-token' }) + participants = integration_message.reload.content_attributes.dig('data', 'participants') + expect(participants["User:#{agent.id}"]).to eq('agent_participant_id') + expect(participants["Contact:#{contact.id}"]).to eq('contact_participant_id') + end + end + + context 'when the stored hook still has legacy Dyte credentials' do + let(:dyte_settings) { { organization_id: 'org_id', api_key: 'dyte_api_key' } } + + it 'returns a normal error response without creating a RealtimeKit client' do + expect(Dyte).not_to receive(:new) + + response = processor.add_participant_to_meeting('m_id', agent) + + expect(response).to eq({ error: I18n.t('errors.dyte.realtimekit_credentials_required') }) + end end end end diff --git a/spec/lib/llm/feature_router_spec.rb b/spec/lib/llm/feature_router_spec.rb new file mode 100644 index 000000000..ea2b9f91f --- /dev/null +++ b/spec/lib/llm/feature_router_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Llm::FeatureRouter do + let(:account) { create(:account) } + + describe '.resolve' do + it 'returns the feature default without an account' do + resolved = described_class.resolve(feature: 'editor') + + expect(resolved).to eq( + feature: 'editor', + provider: 'openai', + model: 'gpt-4.1-mini', + source: :default + ) + end + + it 'uses a valid account model override' do + account.update!(captain_models: { 'editor' => 'gpt-4.1' }) + + resolved = described_class.resolve(feature: 'editor', account: account) + + expect(resolved).to include( + feature: 'editor', + provider: 'openai', + model: 'gpt-4.1', + source: :account_override + ) + end + + it 'resolves GPT-5.2 as the assistant default when Captain V2 is enabled without storing an account override' do + account.enable_features!('captain_integration_v2') + + resolved = described_class.resolve(feature: 'assistant', account: account) + + expect(resolved).to include( + feature: 'assistant', + provider: 'openai', + model: 'gpt-5.2', + source: :default + ) + expect(account.reload.captain_models).to be_nil + end + + it 'keeps account model overrides ahead of the Captain V2 default' do + account.enable_features!('captain_integration_v2') + account.update!(captain_models: { 'assistant' => 'gpt-5.1' }) + + resolved = described_class.resolve(feature: 'assistant', account: account) + + expect(resolved).to include( + model: 'gpt-5.1', + source: :account_override + ) + end + + it 'falls back to the feature default when the account override is invalid' do + account.captain_models = { 'editor' => 'invalid-model' } + + resolved = described_class.resolve(feature: 'editor', account: account) + + expect(resolved).to include( + model: 'gpt-4.1-mini', + source: :default + ) + end + + it 'falls back to the feature default when the account override is blank' do + account.update!(captain_models: { 'editor' => '' }) + + resolved = described_class.resolve(feature: 'editor', account: account) + + expect(resolved).to include( + model: 'gpt-4.1-mini', + source: :default + ) + end + + it 'raises for unknown features' do + expect { described_class.resolve(feature: 'unknown_feature') } + .to raise_error(described_class::UnknownFeatureError, 'Unknown LLM feature: unknown_feature') + end + end +end diff --git a/spec/lib/llm/models_spec.rb b/spec/lib/llm/models_spec.rb new file mode 100644 index 000000000..5692bee9c --- /dev/null +++ b/spec/lib/llm/models_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Llm::Models do + describe '.providers' do + it 'loads provider metadata from the config' do + expect(described_class.providers).to include( + 'openai' => include('display_name' => 'OpenAI') + ) + end + end + + describe '.features' do + it 'keeps every feature default in the allowed model list' do + described_class.features.each do |feature_key, config| + expect(config['models']).to include(config['default']), "#{feature_key} default model must be allowed" + end + end + + it 'references existing models from every feature' do + described_class.features.each do |feature_key, config| + missing_models = config['models'].reject { |model_name| described_class.models.key?(model_name) } + + expect(missing_models).to be_empty, "#{feature_key} references missing models: #{missing_models.join(', ')}" + end + end + + it 'routes document and conversation FAQ generation independently' do + expect(described_class.default_model_for('document_faq_generation')).to eq('gpt-4.1-mini') + expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.2') + end + end + + describe '.models' do + it 'references existing providers from every model' do + missing_providers = described_class.models.filter_map do |model_name, config| + provider = config['provider'] + next if described_class.providers.key?(provider) + + "#{model_name}: #{provider}" + end + + expect(missing_providers).to be_empty + end + end + + describe '.feature_config' do + it 'returns model metadata for a feature' do + config = described_class.feature_config('editor') + + expect(config[:default]).to eq('gpt-4.1-mini') + expect(config[:models].first).to include( + id: 'gpt-4.1-mini', + display_name: 'GPT-4.1 Mini', + provider: 'openai', + credit_multiplier: 1 + ) + end + end +end diff --git a/spec/lib/safe_fetch_spec.rb b/spec/lib/safe_fetch_spec.rb index a124be774..1593a7c52 100644 --- a/spec/lib/safe_fetch_spec.rb +++ b/spec/lib/safe_fetch_spec.rb @@ -249,6 +249,34 @@ RSpec.describe SafeFetch do expect { described_class.fetch(redirect_url) { nil } }.not_to raise_error end end + + it 'strips caller-provided sensitive headers on private network cross-origin redirects' do + redirect_url = 'http://example.com/redirect.png' + private_url = 'http://private.example.com/image.png' + redirected_headers = nil + allow(Resolv).to receive(:getaddresses).with('private.example.com').and_return(['10.0.0.5']) + stub_request(:get, redirect_url).to_return(status: 302, headers: { 'Location' => private_url }) + stub_request(:get, private_url) + .with do |request| + redirected_headers = request.headers.transform_keys(&:downcase) + true + end + .to_return( + status: 200, + body: File.new(Rails.root.join('spec/assets/avatar.png')), + headers: { 'Content-Type' => 'image/png' } + ) + + with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do + described_class.fetch( + redirect_url, + headers: { 'X-API-Key' => 'secret-key' }, + sensitive_headers: ['X-API-Key'] + ) { nil } + end + + expect(redirected_headers).not_to include('x-api-key') + end end context 'with content-type allowlist' do @@ -400,6 +428,47 @@ RSpec.describe SafeFetch do expect(redirected_headers).not_to include('authorization', 'cookie') end + it 'strips caller-provided sensitive headers on cross-origin redirects' do + redirect_url = 'https://example.com/image.png' + redirected_headers = nil + headers = { 'X-API-Key' => 'secret-key' } + + stub_request(:get, url).to_return(status: 302, headers: { 'Location' => redirect_url }) + stub_request(:get, redirect_url) + .with do |request| + redirected_headers = request.headers.transform_keys(&:downcase) + true + end + .to_return(status: 200, body: '', headers: {}) + + described_class.fetch( + url, + headers: headers, + sensitive_headers: ['X-API-Key'], + validate_content_type: false + ) { nil } + + expect(redirected_headers).not_to include('x-api-key') + end + + it 'preserves caller-provided sensitive headers on same-origin redirects' do + redirect_url = 'http://example.com/redirected.png' + + stub_request(:get, url).to_return(status: 302, headers: { 'Location' => '/redirected.png' }) + stub_request(:get, redirect_url) + .with(headers: { 'X-API-Key' => 'secret-key' }) + .to_return(status: 200, body: '', headers: {}) + + described_class.fetch( + url, + headers: { 'X-API-Key' => 'secret-key' }, + sensitive_headers: ['X-API-Key'], + validate_content_type: false + ) { nil } + + expect(WebMock).to have_requested(:get, redirect_url).with(headers: { 'X-API-Key' => 'secret-key' }) + end + it 'raises UnsupportedMethodError for unsupported HTTP methods' do expect { described_class.fetch(url, method: :options) { nil } }.to raise_error do |error| expect(error.class.name).to eq('SafeFetch::UnsupportedMethodError') diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb index cdb9a93cb..db387c5dc 100644 --- a/spec/listeners/action_cable_listener_spec.rb +++ b/spec/listeners/action_cable_listener_spec.rb @@ -13,6 +13,30 @@ describe ActionCableListener do Current.account = nil end + describe '#account_cache_invalidated' do + let!(:event) do + Events::Base.new( + :'account.cache_invalidated', + Time.zone.now, + account: account, + cache_keys: account.cache_keys + ) + end + + it 'sends cache invalidation to account agents and admins' do + expect(ActionCableBroadcastJob).to receive(:perform_later).with( + a_collection_containing_exactly(agent.pubsub_token, admin.pubsub_token), + 'account.cache_invalidated', + { + cache_keys: account.cache_keys, + account_id: account.id + } + ) + + listener.account_cache_invalidated(event) + end + end + describe '#message_created' do let(:event_name) { :'message.created' } let!(:message) do diff --git a/spec/listeners/webhook_listener_spec.rb b/spec/listeners/webhook_listener_spec.rb index b63f43c2f..8af0fd4d3 100644 --- a/spec/listeners/webhook_listener_spec.rb +++ b/spec/listeners/webhook_listener_spec.rb @@ -44,6 +44,50 @@ describe WebhookListener do end end + context 'when API and webhook access is disabled for the account' do + before do + allow(account).to receive(:api_and_webhooks_enabled?).and_return(false) + allow(message).to receive(:inbox).and_return(inbox) + allow(inbox).to receive(:account).and_return(account) + end + + it 'does not trigger account webhooks' do + create(:webhook, inbox: inbox, account: account) + expect(WebhookJob).not_to receive(:perform_later) + listener.message_created(message_created_event) + end + + it 'still triggers API inbox webhooks' do + channel_api = create(:channel_api, account: account) + api_inbox = channel_api.inbox + api_conversation = create(:conversation, account: account, inbox: api_inbox, assignee: user) + api_message = create(:message, message_type: 'outgoing', account: account, inbox: api_inbox, conversation: api_conversation) + api_event = Events::Base.new(event_name, Time.zone.now, message: api_message) + allow(api_message).to receive(:inbox).and_return(api_inbox) + allow(api_inbox).to receive(:account).and_return(account) + expect(WebhookJob).to receive(:perform_later).with( + channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'), + :api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String) + ).once + listener.message_created(api_event) + end + end + + context 'when api_and_webhooks feature is disabled on self-hosted' do + it 'still triggers account webhooks' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) + account.disable_features!('api_and_webhooks') + webhook = create(:webhook, inbox: inbox, account: account) + + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, message.webhook_data.merge(event: 'message_created'), :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once + + listener.message_created(message_created_event) + end + end + context 'when inbox is an API Channel' do it 'triggers webhook if webhook_url is present' do channel_api = create(:channel_api, account: account) diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb index df4c4f41f..576ca882a 100644 --- a/spec/mailers/conversation_reply_mailer_spec.rb +++ b/spec/mailers/conversation_reply_mailer_spec.rb @@ -137,6 +137,34 @@ RSpec.describe ConversationReplyMailer do end end + context 'without summary for a non-email inbox' do + let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) } + let(:conversation) { create(:conversation, assignee: agent, account: account, inbox: inbox) } + let!(:incoming_email_message) do + create(:message, conversation: conversation, account: account, message_type: :incoming, content_type: :incoming_email) + end + let!(:outgoing_message) do + create(:message, conversation: conversation, account: account, message_type: :outgoing, content: 'Outgoing email reply') + end + let(:mail) { described_class.reply_without_summary(conversation, incoming_email_message.id).deliver_now } + + it 'applies the account branded email layout' do + account.enable_features!(:branded_email_templates) + create(:email_template, :layout, account: account, body: 'Account Brand {{ content_for_layout }}') + + expect(mail.decoded).to include('Account Brand') + expect(mail.decoded).to include(outgoing_message.content) + end + + it 'does not apply an installation layout without an account override' do + account.enable_features!(:branded_email_templates) + create(:email_template, :layout, body: 'Installation Brand {{ content_for_layout }}') + + expect(mail.decoded).not_to include('Installation Brand') + expect(mail.decoded).to include(outgoing_message.content) + end + end + context 'with references header' do let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload } let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') } @@ -243,6 +271,73 @@ RSpec.describe ConversationReplyMailer do expect(mail.decoded).to include message.content end + it 'does not apply branded email layout when feature is disabled' do + create( + :email_template, + :layout, + account: account, + inbox: conversation.inbox, + body: 'Inbox Brand {{ content_for_layout }}' + ) + + expect(mail.decoded).not_to include('Inbox Brand') + expect(mail.decoded).to include(message.content) + end + + it 'exposes the reply sender in inbox branded email layouts' do + account.enable_features!(:branded_email_templates) + conversation.inbox.update!(business_name: 'Acme Support') + create( + :email_template, + :layout, + account: account, + inbox: conversation.inbox, + body: [ + ' {{ inbox.business_name }} ', + '{{ content_for_layout }}', + '{{ agent.email }}', + '' + ].join + ) + + expect(mail.decoded).to include('Acme Support') + expect(mail.decoded).to include(message.content) + expect(message.sender).not_to eq(agent) + expect(mail.decoded).to include(message.sender.email) + expect(mail.decoded).to include(message.sender.available_name) + end + + it 'falls back to account branded email layout when inbox layout is absent' do + account.enable_features!(:branded_email_templates) + create( + :email_template, + :layout, + account: account, + body: 'Account Brand {{ content_for_layout }}' + ) + + expect(mail.decoded).to include('Account Brand') + expect(mail.decoded).to include(message.content) + end + + it 'applies inbox branded email layout to template messages' do + account.enable_features!(:branded_email_templates) + create( + :email_template, + :layout, + account: account, + inbox: conversation.inbox, + body: 'Template Brand {{ content_for_layout }}' + ) + template_message = create(:message, conversation: conversation, account: account, message_type: :template, content_type: :text, + content: 'Automation template response', sender: agent) + + template_mail = described_class.email_reply(template_message).deliver_now + + expect(template_mail.decoded).to include('Template Brand') + expect(template_mail.decoded).to include('Automation template response') + end + it 'builds messageID properly' do expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}") end @@ -736,6 +831,22 @@ RSpec.describe ConversationReplyMailer do it 'sets the correct in reply to id' do expect(mail.in_reply_to).to eq("account/#{conversation.account.id}/conversation/#{conversation.uuid}@#{domain}") end + + it 'applies inbox branded email layout to conversation transcript' do + new_account.enable_features!(:branded_email_templates) + create( + :email_template, + :layout, + account: new_account, + inbox: conversation.inbox, + body: 'Transcript Brand {{ content_for_layout }}' + ) + + transcript = described_class.conversation_transcript(conversation, 'customer@example.com').deliver_now + + expect(transcript.decoded).to include('Transcript Brand') + expect(transcript.decoded).to include(message.content) + end end end end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 38ca9694a..cbad87363 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -50,6 +50,30 @@ RSpec.describe Account do end end + describe '#api_and_webhooks_enabled?' do + it 'is enabled for self-hosted accounts regardless of the stored feature flag' do + account = create(:account) + account.disable_features!('api_and_webhooks') + + expect(account.api_and_webhooks_enabled?).to be true + end + end + + describe 'captain defaults for new accounts' do + it 'does not store Captain model overrides or enable premium Captain features' do + InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!( + value: Featurable::FEATURE_LIST, + locked: true + ) + + account = create(:account) + + expect(account).not_to be_feature_enabled('captain_integration') + expect(account).not_to be_feature_enabled('captain_integration_v2') + expect(account.captain_models).to be_nil + end + end + describe 'conversation unread counts feature flag' do let(:account) { create(:account) } let(:inbox) { create(:inbox, account: account) } @@ -88,6 +112,44 @@ RSpec.describe Account do end end + describe 'feature flag columns' do + let(:account) { described_class.new(name: 'Test Account') } + + it 'configures the account feature flag extension column' do + expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1') + expect(described_class.flag_mapping['feature_flags_ext_1']).to eq( + feature_whatsapp_manual_transfer: 1, + feature_data_import: 1 << 1, + feature_api_and_webhooks: 1 << 2, + feature_whatsapp_reconfigure: 1 << 3, + feature_whatsapp_embedded_signup_inbox_creation: 1 << 4 + ) + expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_whatsapp_manual_transfer]).to eq(1) + expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_data_import]).to eq(2) + expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_whatsapp_embedded_signup_inbox_creation]).to eq(16) + end + + it 'keeps existing feature flags on the original column' do + expect(described_class.flag_mapping['feature_flags'][:feature_inbound_emails]).to eq(1) + expect(described_class.flag_mapping['feature_flags'][:feature_advanced_assignment]).to eq(1 << 62) + end + + it 'keeps bulk selected feature assignment compatible with existing feature names' do + account.selected_feature_flags = [:feature_ip_lookup, :feature_assignment_v2, :feature_advanced_assignment, :feature_data_import] + + expect(account).to be_feature_ip_lookup + expect(account).to be_feature_assignment_v2 + expect(account).to be_feature_advanced_assignment + expect(account).to be_feature_data_import + expect(account.selected_feature_flags).to contain_exactly( + :feature_ip_lookup, + :feature_assignment_v2, + :feature_advanced_assignment, + :feature_data_import + ) + end + end + describe 'inbound_email_domain' do let(:account) { create(:account) } @@ -337,6 +399,10 @@ RSpec.describe Account do let(:account) { create(:account) } describe 'with no saved preferences' do + before do + account.update!(captain_models: nil) + end + it 'returns defaults from llm.yml' do prefs = account.captain_preferences @@ -346,6 +412,13 @@ RSpec.describe Account do expect(prefs[:models][feature]).to eq(Llm::Models.default_model_for(feature)) end end + + it 'returns GPT-5.2 for assistant when Captain V2 is enabled' do + account.enable_features!('captain_integration_v2') + + expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2') + expect(account.reload.captain_models).to be_nil + end end describe 'with saved model preferences' do @@ -385,6 +458,19 @@ RSpec.describe Account do expect(account).to be_valid end + + it 'rejects unknown feature keys' do + account.captain_models = { 'unknown_feature' => 'gpt-4.1' } + + expect(account).not_to be_valid + expect(account.errors[:captain_models]).to include("'unknown_feature' is not a known feature") + end + + it 'removes blank model overrides before saving' do + account.update!(captain_models: { 'editor' => '', 'assistant' => 'gpt-5.2' }) + + expect(account.captain_models).to eq('assistant' => 'gpt-5.2') + end end end end diff --git a/spec/models/account_user_spec.rb b/spec/models/account_user_spec.rb index e5a560fe9..394654db4 100644 --- a/spec/models/account_user_spec.rb +++ b/spec/models/account_user_spec.rb @@ -42,4 +42,43 @@ RSpec.describe AccountUser do expect(user.assigned_conversations.count).to eq(0) end end + + describe 'filtered unread count invalidation' do + let(:account) { create(:account) } + let(:user) { create(:user) } + let(:invalidator) { instance_double(Conversations::UnreadCounts::FilteredCountInvalidator, user_visibility_changed!: true) } + + before do + allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).and_return(invalidator) + allow(Rails.configuration.dispatcher).to receive(:dispatch) + end + + it 'invalidates filtered counts when the user is added to an account' do + create(:account_user, account: account, user: user) + + expect(invalidator).to have_received(:user_visibility_changed!).with(user_id: user.id) + end + + it 'invalidates filtered counts when the user role changes' do + account_user = create(:account_user, account: account, user: user) + + account_user.update!(role: :administrator) + + expect(invalidator).to have_received(:user_visibility_changed!).with(user_id: user.id).twice + expect(Rails.configuration.dispatcher).to have_received(:dispatch).with( + 'account.cache_invalidated', + kind_of(Time), + account: account, + cache_keys: account.cache_keys + ) + end + + it 'invalidates filtered counts when the user is removed from an account' do + account_user = create(:account_user, account: account, user: user) + + account_user.destroy! + + expect(invalidator).to have_received(:user_visibility_changed!).with(user_id: user.id).twice + end + end end diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb index 04466ccd1..cdad2d9f4 100644 --- a/spec/models/article_spec.rb +++ b/spec/models/article_spec.rb @@ -207,4 +207,29 @@ RSpec.describe Article do expect(article.to_llm_text).to eq(expected_output) end end + + describe '.update_positions' do + let!(:article_a) { create(:article, portal: portal_1, category: category_1, author: user, position: 10) } + let!(:article_b) { create(:article, portal: portal_1, category: category_1, author: user, position: 11) } + let!(:article_c) { create(:article, portal: portal_1, category: category_1, author: user, position: 30) } + + it 're-spaces the category to clean gaps and places a collided move after its tie' do + # Dropping C into the tight 10/11 gap gives a floored midpoint of 10, colliding with A + positions = described_class.update_positions(portal: portal_1, positions_hash: { article_c.id => 10 }) + + expect(article_a.reload.position).to eq(10) + expect(article_c.reload.position).to eq(20) + expect(article_b.reload.position).to eq(30) + expect(positions).to eq(article_a.id => 10, article_c.id => 20, article_b.id => 30) + end + + it 'leaves a lone article untouched and returns nothing to sync' do + lone = create(:article, portal: portal_1, category: create(:category, portal_id: portal_1.id), author: user, position: 20) + + positions = described_class.update_positions(portal: portal_1, positions_hash: { lone.id => 20 }) + + expect(lone.reload.position).to eq(20) + expect(positions).to be_empty + end + end end diff --git a/spec/models/assignment_policy_spec.rb b/spec/models/assignment_policy_spec.rb index 1a97bbda0..2eb9ac57b 100644 --- a/spec/models/assignment_policy_spec.rb +++ b/spec/models/assignment_policy_spec.rb @@ -28,6 +28,19 @@ RSpec.describe AssignmentPolicy do end end + describe 'exclude_older_than_hours validations' do + it 'requires exclude_older_than_hours to be greater than 0' do + policy = build(:assignment_policy, exclude_older_than_hours: 0) + expect(policy).not_to be_valid + expect(policy.errors[:exclude_older_than_hours]).to include('must be greater than 0') + end + + it 'allows exclude_older_than_hours to be nil' do + policy = build(:assignment_policy, exclude_older_than_hours: nil) + expect(policy).to be_valid + end + end + describe 'enum values' do let(:assignment_policy) { create(:assignment_policy) } diff --git a/spec/models/campaign_spec.rb b/spec/models/campaign_spec.rb index 2be6bd588..e4bdd05e4 100644 --- a/spec/models/campaign_spec.rb +++ b/spec/models/campaign_spec.rb @@ -3,11 +3,48 @@ require 'rails_helper' RSpec.describe Campaign do + let(:store) { Conversations::UnreadCounts::FilteredCountStore } + describe 'associations' do it { is_expected.to belong_to(:account) } it { is_expected.to belong_to(:inbox) } end + describe '#destroy' do + let(:account) { create(:account) } + let(:campaign) { create(:campaign, account: account) } + + before do + campaign + allow(Rails.configuration.dispatcher).to receive(:dispatch) + end + + after do + Redis::Alfred.delete(store.conversation_version_key(account.id)) + end + + it 'invalidates and refreshes filtered counts when conversations are detached from a deleted campaign' do + account.enable_features!(:unread_count_for_filters) + + expect do + campaign.destroy! + end.to change { store.conversation_version(account.id) }.by(1) + + expect(Rails.configuration.dispatcher).to have_received(:dispatch).with( + 'account.cache_invalidated', + kind_of(Time), + account: account, + cache_keys: account.cache_keys + ) + end + + it 'does not notify filtered count refreshes when the feature is disabled' do + campaign.destroy! + + expect(Rails.configuration.dispatcher).not_to have_received(:dispatch) + end + end + describe '.before_create' do let(:account) { create(:account) } let(:website_channel) { create(:channel_widget, account: account) } diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb index cd4fa3a21..10a192004 100644 --- a/spec/models/channel/whatsapp_spec.rb +++ b/spec/models/channel/whatsapp_spec.rb @@ -42,8 +42,18 @@ RSpec.describe Channel::Whatsapp do body: { data: [{ id: '123456789', name: 'test_template' }] }.to_json) + stub_request(:get, 'https://graph.facebook.com/v14.0//phone_numbers?fields=id&limit=100&access_token=test_key') + .to_return(status: 200, body: { data: [{ id: 'random_id' }] }.to_json, headers: { 'Content-Type' => 'application/json' }) expect(channel.save).to be(true) end + + it 'validates false when phone number id is wrong' do + stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key') + .to_return(status: 200, body: { data: [] }.to_json) + stub_request(:get, 'https://graph.facebook.com/v14.0//phone_numbers?fields=id&limit=100&access_token=test_key') + .to_return(status: 200, body: { data: [{ id: 'another_phone_id' }] }.to_json, headers: { 'Content-Type' => 'application/json' }) + expect(channel.save).to be(false) + end end describe 'webhook_verify_token' do diff --git a/spec/models/concerns/captain_featurable_spec.rb b/spec/models/concerns/captain_featurable_spec.rb index 7221ad055..0fb145142 100644 --- a/spec/models/concerns/captain_featurable_spec.rb +++ b/spec/models/concerns/captain_featurable_spec.rb @@ -58,15 +58,6 @@ RSpec.describe CaptainFeaturable do end describe 'model accessor methods' do - context 'when no models are explicitly configured' do - it 'returns default models for all features' do - Llm::Models.feature_keys.each do |feature_key| - expected_default = Llm::Models.default_model_for(feature_key) - expect(account.send("captain_#{feature_key}_model")).to eq(expected_default) - end - end - end - context 'when models are explicitly configured' do before do account.update!(captain_models: { diff --git a/spec/models/concerns/featurable_spec.rb b/spec/models/concerns/featurable_spec.rb new file mode 100644 index 000000000..8f0b64b05 --- /dev/null +++ b/spec/models/concerns/featurable_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Featurable do + describe '.feature_flag_mappings_for' do + it 'maps features to the default feature_flags column when column is omitted' do + mappings = described_class.feature_flag_mappings_for([ + { 'name' => 'inbound_emails' }, + { 'name' => 'ip_lookup' } + ]) + + expect(mappings['feature_flags']).to eq( + 1 => :feature_inbound_emails, + 2 => :feature_ip_lookup + ) + expect(mappings['feature_flags_ext_1']).to eq({}) + end + + it 'maps extension flags to feature_flags_ext_1 with independent bit positions' do + mappings = described_class.feature_flag_mappings_for([ + { 'name' => 'inbound_emails' }, + { 'name' => 'ext_one', 'column' => 'feature_flags_ext_1' }, + { 'name' => 'ext_two', 'column' => 'feature_flags_ext_1' } + ]) + + expect(mappings['feature_flags']).to eq(1 => :feature_inbound_emails) + expect(mappings['feature_flags_ext_1']).to eq( + 1 => :feature_ext_one, + 2 => :feature_ext_two + ) + end + + it 'raises when a feature references an unknown flag column' do + expect do + described_class.feature_flag_mappings_for([ + { 'name' => 'unknown_column_feature', 'column' => 'feature_flags_3' } + ]) + end.to raise_error(ArgumentError, /Unknown account feature flag column: feature_flags_3/) + end + + it 'raises when a flag column has more than the supported number of features' do + features = Array.new(64) { |index| { 'name' => "feature_#{index}" } } + + expect do + described_class.feature_flag_mappings_for(features) + end.to raise_error(ArgumentError, /feature_flags supports up to 63 features/) + end + end +end diff --git a/spec/models/conversation_participants_spec.rb b/spec/models/conversation_participants_spec.rb index c078f69f6..f8801d461 100644 --- a/spec/models/conversation_participants_spec.rb +++ b/spec/models/conversation_participants_spec.rb @@ -29,4 +29,30 @@ RSpec.describe ConversationParticipant do expect(participant.errors.messages[:user]).to eq(['must have inbox access']) end end + + describe 'filtered unread count invalidation' do + let(:account) { create(:account) } + let(:conversation) { create(:conversation, account: account) } + let(:user) { create(:user, account: account) } + let(:store) { Conversations::UnreadCounts::FilteredCountStore } + + before do + account.enable_features!(:unread_count_for_filters) + create(:inbox_member, inbox: conversation.inbox, user: user) + end + + it 'invalidates the participant built-in filter version when a participant is added' do + expect do + create(:conversation_participant, account: account, conversation: conversation, user: user) + end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1) + end + + it 'invalidates the participant built-in filter version when a participant is removed' do + participant = create(:conversation_participant, account: account, conversation: conversation, user: user) + + expect do + participant.destroy! + end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1) + end + end end diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 58d64ea94..c67aa0604 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -117,6 +117,7 @@ RSpec.describe Conversation do end let(:assignment_mailer) { instance_double(AssignmentMailer, deliver: true) } let(:label) { create(:label, account: account) } + let(:filtered_store) { Conversations::UnreadCounts::FilteredCountStore } before do create(:inbox_member, user: old_assignee, inbox: conversation.inbox) @@ -125,6 +126,10 @@ RSpec.describe Conversation do Current.user = old_assignee end + after do + Redis::Alfred.delete(filtered_store.conversation_version_key(account.id)) + end + it 'sends conversation updated event if labels are updated' do conversation.update(label_list: [label.title]) changed_attributes = conversation.previous_changes @@ -139,6 +144,33 @@ RSpec.describe Conversation do ) end + it 'invalidates filtered counts without sending conversation updated event if last activity time is updated' do + account.enable_features!(:unread_count_for_filters) + + expect do + conversation.update!(last_activity_at: 1.hour.from_now) + end.to change { filtered_store.conversation_version(account.id) }.by(1) + expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with( + described_class::CONVERSATION_UPDATED, + kind_of(Time), + anything + ) + end + + it 'invalidates filtered counts without sending conversation updated event if campaign assignment is updated' do + account.enable_features!(:unread_count_for_filters) + campaign = create(:campaign, account: account, inbox: conversation.inbox) + + expect do + conversation.update!(campaign: campaign) + end.to change { filtered_store.conversation_version(account.id) }.by(1) + expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with( + described_class::CONVERSATION_UPDATED, + kind_of(Time), + anything + ) + end + it 'runs after_update callbacks' do conversation.update( status: :resolved, @@ -174,20 +206,47 @@ RSpec.describe Conversation do .with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true) end - it 'will run conversation_updated event for conversation_language in additional_attributes' do - conversation.additional_attributes[:conversation_language] = 'es' - conversation.save! + it 'will run conversation_updated event for conversation language changes' do + conversation.update!(additional_attributes: { 'conversation_language' => 'es' }) changed_attributes = conversation.previous_changes + expect(Rails.configuration.dispatcher).to have_received(:dispatch) .with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: false, changed_attributes: changed_attributes, performed_by: nil) end - it 'will not run conversation_updated event for bowser_language in additional_attributes' do - conversation.additional_attributes[:browser_language] = 'es' + it 'invalidates filtered counts without sending conversation_updated for filtered-only additional_attributes' do + account.enable_features!(:unread_count_for_filters) + + expect do + conversation.update!(additional_attributes: { 'browser_language' => 'es' }) + end.to change { filtered_store.conversation_version(account.id) }.by(1) + expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with( + described_class::CONVERSATION_UPDATED, + kind_of(Time), + anything + ) + end + + it 'invalidates filtered counts when filterable additional_attributes are removed' do + account.enable_features!(:unread_count_for_filters) + conversation.update!(additional_attributes: { 'referer' => 'https://www.chatwoot.com/' }) + + expect do + conversation.update!(additional_attributes: {}) + end.to change { filtered_store.conversation_version(account.id) }.by(1) + expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with( + described_class::CONVERSATION_UPDATED, + kind_of(Time), + anything + ) + end + + it 'will not run conversation_updated event for non-filterable additional_attributes' do + conversation.additional_attributes[:source_id] = 'es' conversation.save! expect(Rails.configuration.dispatcher).not_to have_received(:dispatch) - .with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true) + .with(described_class::CONVERSATION_UPDATED, kind_of(Time), anything) end it 'creates conversation activities' do @@ -205,7 +264,8 @@ RSpec.describe Conversation do expect(Conversations::ActivityMessageJob) .to(have_been_enqueued.at_least(:once) .with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity, - content: "Conversation was marked resolved by #{old_assignee.name}" })) + content: "Conversation was marked resolved by #{old_assignee.name}", + content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } } })) expect(Conversations::ActivityMessageJob) .to(have_been_enqueued.at_least(:once) .with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity, @@ -228,7 +288,8 @@ RSpec.describe Conversation do expect { conversation2.update(status: :resolved) } .to have_enqueued_job(Conversations::ActivityMessageJob) .with(conversation2, { account_id: conversation2.account_id, inbox_id: conversation2.inbox_id, message_type: :activity, - content: system_resolved_message }) + content: system_resolved_message, + content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } } }) end end diff --git a/spec/models/custom_attribute_definition_spec.rb b/spec/models/custom_attribute_definition_spec.rb index c529fc609..8cf9065e0 100644 --- a/spec/models/custom_attribute_definition_spec.rb +++ b/spec/models/custom_attribute_definition_spec.rb @@ -68,5 +68,51 @@ RSpec.describe CustomAttributeDefinition do expect(cad.attribute_display_name).to eq('Order Date') end end + + describe 'filtered unread count invalidation' do + let(:invalidator) { instance_double(Conversations::UnreadCounts::FilteredCountInvalidator, custom_attribute_definition_changed!: true) } + + before do + allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator) + allow(Rails.configuration.dispatcher).to receive(:dispatch) + end + + it 'invalidates conversation filters when a conversation custom attribute definition changes' do + cad = create(:custom_attribute_definition, account: account, attribute_model: 'conversation_attribute') + + cad.update!(attribute_display_name: 'Updated Order Date') + + expect(invalidator).to have_received(:custom_attribute_definition_changed!).with(cad) + expect(Rails.configuration.dispatcher).to have_received(:dispatch).with( + 'account.cache_invalidated', + kind_of(Time), + account: account, + cache_keys: account.cache_keys + ) + end + + it 'invalidates conversation filters when a conversation custom attribute definition is deleted' do + cad = create(:custom_attribute_definition, account: account, attribute_model: 'conversation_attribute') + + cad.destroy! + + expect(invalidator).to have_received(:custom_attribute_definition_changed!).with(cad) + expect(Rails.configuration.dispatcher).to have_received(:dispatch).with( + 'account.cache_invalidated', + kind_of(Time), + account: account, + cache_keys: account.cache_keys + ) + end + + it 'ignores contact custom attribute definition changes' do + cad = create(:custom_attribute_definition, account: account, attribute_model: 'contact_attribute') + + cad.update!(attribute_display_name: 'Updated Contact Field') + + expect(invalidator).not_to have_received(:custom_attribute_definition_changed!) + expect(Rails.configuration.dispatcher).not_to have_received(:dispatch) + end + end end end diff --git a/spec/models/custom_filter_spec.rb b/spec/models/custom_filter_spec.rb new file mode 100644 index 000000000..ecb3f2baa --- /dev/null +++ b/spec/models/custom_filter_spec.rb @@ -0,0 +1,58 @@ +require 'rails_helper' + +RSpec.describe CustomFilter do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:store) { Conversations::UnreadCounts::FilteredCountStore } + + before do + account.enable_features!(:unread_count_for_filters) + end + + describe 'filtered unread count invalidation' do + it 'invalidates the folder index and filter version when a conversation filter is created' do + custom_filter = nil + + expect do + custom_filter = create(:custom_filter, account: account, user: user, filter_type: :conversation) + end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1) + expect(store.filter_version(account_id: account.id, filter_id: custom_filter.id)).to eq(1) + end + + it 'invalidates only the filter version when the query changes' do + custom_filter = create(:custom_filter, account: account, user: user, filter_type: :conversation) + folder_index_version = store.folder_index_version(account_id: account.id, user_id: user.id) + + expect do + custom_filter.update!(query: { payload: [{ attribute_key: 'status', values: ['resolved'] }] }) + end.to(change { store.filter_version(account_id: account.id, filter_id: custom_filter.id) }.by(1)) + expect(store.folder_index_version(account_id: account.id, user_id: user.id)).to eq(folder_index_version) + end + + it 'does not invalidate counts when only the name changes' do + custom_filter = create(:custom_filter, account: account, user: user, filter_type: :conversation) + + expect do + custom_filter.update!(name: 'Renamed filter') + end.not_to(change { store.filter_version(account_id: account.id, filter_id: custom_filter.id) }) + end + + it 'invalidates the folder index and deletes the count when a conversation filter is destroyed' do + custom_filter = create(:custom_filter, account: account, user: user, filter_type: :conversation) + store.write_filter_count!( + account_id: account.id, + filter_id: custom_filter.id, + user_id: user.id, + count: 3, + account_version: 0, + filter_version: 0, + owner_built_in_filter_version: 0 + ) + + expect do + custom_filter.destroy! + end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil + end + end +end diff --git a/spec/models/data_import_spec.rb b/spec/models/data_import_spec.rb index 2b7429cf1..fc31ebb1d 100644 --- a/spec/models/data_import_spec.rb +++ b/spec/models/data_import_spec.rb @@ -11,6 +11,18 @@ RSpec.describe DataImport do end end + describe 'access token encryption' do + it 'encrypts the Intercom access token at rest' do + skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured? + + data_import = create(:data_import, :intercom, access_token: 'intercom-secret') + stored_value = data_import.reload.read_attribute_before_type_cast(:access_token).to_s + + expect(stored_value).not_to include('intercom-secret') + expect(data_import.access_token).to eq('intercom-secret') + end + end + describe 'callbacks' do let(:data_import) { build(:data_import) } @@ -20,4 +32,36 @@ RSpec.describe DataImport do end.to have_enqueued_job(DataImportJob).with(data_import).on_queue('low') end end + + describe '#abandon!' do + let(:account) { create(:account) } + let(:data_import) do + create( + :data_import, :intercom, + account: account, + status: :processing + ) + end + + before do + account.enable_features!('data_import') + end + + it 'abandons active Intercom imports', :aggregate_failures do + data_import.abandon! + + expect(data_import).to be_abandoned + expect(data_import.abandoned_at).to be_present + end + + it 'does not overwrite terminal status from a stale instance', :aggregate_failures do + stale_import = described_class.find(data_import.id) + data_import.update!(status: :completed, completed_at: 1.minute.ago) + + stale_import.abandon! + + expect(data_import.reload).to be_completed + expect(data_import.abandoned_at).to be_nil + end + end end diff --git a/spec/models/email_template_spec.rb b/spec/models/email_template_spec.rb new file mode 100644 index 000000000..8cc9e667f --- /dev/null +++ b/spec/models/email_template_spec.rb @@ -0,0 +1,126 @@ +require 'rails_helper' + +RSpec.describe EmailTemplate do + describe 'validations' do + it 'allows the same layout name across installation, account, and inbox scopes' do + account = create(:account) + inbox = create(:inbox, :with_email, account: account) + + create(:email_template, :layout, account: nil) + create(:email_template, :layout, account: account) + inbox_template = build(:email_template, :layout, account: account, inbox: inbox) + + expect(inbox_template).to be_valid + end + + it 'allows an account-scoped layout after an inbox-scoped layout' do + account = create(:account) + inbox = create(:inbox, :with_email, account: account) + create(:email_template, :layout, account: account, inbox: inbox) + + account_template = build(:email_template, :layout, account: account) + + expect(account_template).to be_valid + end + + it 'allows an installation-scoped layout after account and inbox-scoped layouts' do + account = create(:account) + inbox = create(:inbox, :with_email, account: account) + create(:email_template, :layout, account: account) + create(:email_template, :layout, account: account, inbox: inbox) + + installation_template = build(:email_template, :layout, account: nil) + + expect(installation_template).to be_valid + end + + it 'rejects duplicate installation-scoped templates' do + create(:email_template) + duplicate_template = build(:email_template) + + expect(duplicate_template).not_to be_valid + expect(duplicate_template.errors[:name]).to include('has already been taken') + end + + it 'rejects duplicate account-scoped templates' do + account = create(:account) + create(:email_template, account: account) + duplicate_template = build(:email_template, account: account) + + expect(duplicate_template).not_to be_valid + expect(duplicate_template.errors[:name]).to include('has already been taken') + end + + it 'rejects duplicate inbox-scoped templates' do + account = create(:account) + inbox = create(:inbox, :with_email, account: account) + create(:email_template, account: account, inbox: inbox) + duplicate_template = build(:email_template, account: account, inbox: inbox) + + expect(duplicate_template).not_to be_valid + expect(duplicate_template.errors[:name]).to include('has already been taken') + end + + it 'requires branded layouts to include content_for_layout' do + template = build(:email_template, name: EmailTemplate::BRANDED_LAYOUT_NAME, template_type: :layout, body: 'No slot') + + expect(template).not_to be_valid + expect(template.errors[:body]).to include('must include {{ content_for_layout }}') + end + + it 'validates liquid syntax' do + template = build(:email_template, body: '{{ broken ') + + expect(template).not_to be_valid + expect(template.errors[:body].first).to include('has invalid Liquid syntax') + end + + it 'requires account to match inbox account when both are present' do + inbox = create(:inbox, :with_email) + other_account = create(:account) + template = build(:email_template, :layout, account: other_account, inbox: inbox) + + expect(template).not_to be_valid + expect(template.errors[:account]).to include('must match inbox account') + end + end + + describe '.branded_layout_for' do + it 'uses inbox, account, then installation fallback order' do + account = create(:account) + inbox = create(:inbox, :with_email, account: account) + create(:email_template, :layout, body: 'Global {{ content_for_layout }}') + account_template = create(:email_template, :layout, account: account, body: 'Account {{ content_for_layout }}') + + expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en)).to eq(account_template) + + inbox_template = create(:email_template, :layout, account: account, inbox: inbox, body: 'Inbox {{ content_for_layout }}') + + expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en)).to eq(inbox_template) + end + end + + describe '.update_account_branded_layout!' do + it 'creates and updates the account-scoped branded layout' do + account = create(:account) + + described_class.update_account_branded_layout!(account: account, body: 'Account {{ content_for_layout }}') + + template = described_class.account_branded_layout_template_for(account) + expect(template.body).to eq('Account {{ content_for_layout }}') + + described_class.update_account_branded_layout!(account: account, body: 'Updated {{ content_for_layout }}') + + expect(template.reload.body).to eq('Updated {{ content_for_layout }}') + end + + it 'clears the account-scoped branded layout for blank bodies' do + account = create(:account) + create(:email_template, :layout, account: account) + + described_class.update_account_branded_layout!(account: account, body: '') + + expect(described_class.account_branded_layout_template_for(account)).to be_nil + end + end +end diff --git a/spec/models/inbox_member_spec.rb b/spec/models/inbox_member_spec.rb index b081c546c..67f662cac 100644 --- a/spec/models/inbox_member_spec.rb +++ b/spec/models/inbox_member_spec.rb @@ -18,4 +18,46 @@ RSpec.describe InboxMember do end end end + + describe 'filtered unread count invalidation' do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:user) { create(:user) } + let(:store) { Conversations::UnreadCounts::FilteredCountStore } + + before do + account.enable_features!(:unread_count_for_filters) + end + + it 'invalidates the user built-in filter version when inbox access is added' do + expect do + create(:inbox_member, inbox: inbox, user: user) + end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1) + end + + it 'invalidates the user built-in filter version when inbox access is removed' do + inbox_member = create(:inbox_member, inbox: inbox, user: user) + + expect do + inbox_member.destroy! + end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1) + end + + it 'invalidates the user built-in filter version when the parent inbox is removed' do + create(:inbox_member, inbox: inbox, user: user) + + expect do + perform_enqueued_jobs { inbox.destroy! } + end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1) + end + + it 'invalidates administrator built-in filter versions when the parent inbox is removed' do + admin = create(:user) + create(:account_user, account: account, user: admin, role: :administrator) + + expect do + perform_enqueued_jobs { inbox.destroy! } + end.to change { store.built_in_filter_version(account_id: account.id, user_id: admin.id) }.by(1) + end + end end diff --git a/spec/models/inbox_spec.rb b/spec/models/inbox_spec.rb index 92fddcd78..617a74388 100644 --- a/spec/models/inbox_spec.rb +++ b/spec/models/inbox_spec.rb @@ -41,6 +41,34 @@ RSpec.describe Inbox do it_behaves_like 'avatarable' end + describe 'account teardown' do + it 'destroys an orphaned inbox after its account has been deleted' do + account = create(:account) + inbox = create(:inbox, account: account) + account.delete + + orphaned_inbox = described_class.find(inbox.id) + + expect { orphaned_inbox.destroy! }.not_to raise_error + end + end + + describe 'filtered unread count invalidation' do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:store) { Conversations::UnreadCounts::FilteredCountStore } + + before do + account.enable_features!(:unread_count_for_filters) + end + + it 'invalidates saved folder snapshots when destroyed' do + expect do + inbox.destroy! + end.to change { store.conversation_version(account.id) }.by(1) + end + end + describe '#add_members' do let(:inbox) { FactoryBot.create(:inbox) } diff --git a/spec/models/integrations/hook_spec.rb b/spec/models/integrations/hook_spec.rb index 369ea8ca8..aa09dee68 100644 --- a/spec/models/integrations/hook_spec.rb +++ b/spec/models/integrations/hook_spec.rb @@ -177,4 +177,132 @@ RSpec.describe Integrations::Hook do expect(hook).to be_valid end end + + describe 'cloudflare realtimekit credential validation' do + let(:account) { create(:account) } + let(:settings) { { 'account_id' => 'account_id', 'app_id' => 'app_id', 'api_token' => 'api_token' } } + + it 'prevents saving a RealtimeKit hook with an invalid API token' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(false, :invalid_api_token)) + + hook = build(:integrations_hook, :dyte, account: account, settings: settings) + + expect(hook).not_to be_valid + expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.invalid_api_token')) + end + + it 'prevents saving a RealtimeKit hook with an invalid account or missing token permissions' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(false, :invalid_account_or_permissions)) + + hook = build(:integrations_hook, :dyte, account: account, settings: settings) + + expect(hook).not_to be_valid + expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.invalid_account_or_permissions')) + end + + it 'prevents saving a RealtimeKit hook when the app is not found' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(false, :app_not_found)) + + hook = build(:integrations_hook, :dyte, account: account, settings: settings) + + expect(hook).not_to be_valid + expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.app_not_found')) + end + + it 'allows saving a RealtimeKit hook with valid credentials' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(true)) + + hook = build(:integrations_hook, :dyte, account: account, settings: settings) + + expect(hook).to be_valid + end + + it 'skips validation when an enabled RealtimeKit hook is saved without changing credentials' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(true)) + hook = create(:integrations_hook, :dyte, account: account, settings: settings) + + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(false, :invalid_api_token)) + hook.settings['account_id'] = 'account_id' + + expect(hook.save).to be true + end + + it 'validates when a disabled RealtimeKit hook is re-enabled' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(true)) + hook = create(:integrations_hook, :dyte, account: account, settings: settings) + hook.update!(status: :disabled) + + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .with('account_id', 'app_id', 'api_token') + .and_return(cloudflare_validator_result(false, :invalid_api_token)) + + expect(hook.update(status: :enabled)).to be false + expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.invalid_api_token')) + end + + it 'skips validation for disabled RealtimeKit hooks' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(true)) + hook = create(:integrations_hook, :dyte, account: account, settings: settings) + + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(false, :invalid_api_token)) + hook.disable + + expect(hook.reload).to be_disabled + end + + it 'allows disabling a persisted legacy Dyte hook without RealtimeKit credentials' do + hook = build(:integrations_hook, :dyte, account: account, settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' }) + hook.save!(validate: false) + + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(false, :invalid_api_token)) + + expect(hook.disable).to be true + expect(hook.reload).to be_disabled + end + + it 'allows re-enabling a persisted legacy Dyte hook without RealtimeKit credential validation' do + hook = build(:integrations_hook, :dyte, account: account, settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' }) + hook.save!(validate: false) + hook.disable + + expect(Integrations::Cloudflare::RealtimeKitCredentialsValidator).not_to receive(:validate) + + expect(hook.update(status: :enabled)).to be true + expect(hook.reload).to be_enabled + end + + it 'validates settings when a legacy Dyte hook settings payload is changed' do + hook = build(:integrations_hook, :dyte, account: account, settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' }) + hook.save!(validate: false) + + hook.settings = { 'account_id' => 'account_id' } + + expect(hook).not_to be_valid + expect(hook.errors[:settings]).to include(': Invalid settings data') + end + + it 'rejects new legacy Dyte hooks' do + hook = build(:integrations_hook, :dyte, + account: account, + status: :disabled, + settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' }) + + expect(hook).not_to be_valid + expect(hook.errors[:settings]).to include(': Invalid settings data') + end + end + + def cloudflare_validator_result(success, error = nil) + Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(success, error) + end end diff --git a/spec/models/team_member_spec.rb b/spec/models/team_member_spec.rb index f37f70425..d57c59996 100644 --- a/spec/models/team_member_spec.rb +++ b/spec/models/team_member_spec.rb @@ -1,8 +1,51 @@ require 'rails_helper' RSpec.describe TeamMember do + include ActiveJob::TestHelper + describe 'associations' do it { is_expected.to belong_to(:team) } it { is_expected.to belong_to(:user) } end + + describe 'filtered unread count invalidation' do + let(:account) { create(:account) } + let(:team) { create(:team, account: account) } + let(:user) { create(:user) } + let(:store) { Conversations::UnreadCounts::FilteredCountStore } + + before do + account.enable_features!(:unread_count_for_filters) + end + + it 'invalidates the user built-in filter version when team access is added' do + expect do + create(:team_member, team: team, user: user) + end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1) + end + + it 'invalidates the user built-in filter version when team access is removed' do + team_member = create(:team_member, team: team, user: user) + + expect do + team_member.destroy! + end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1) + end + + it 'invalidates the user built-in filter version when the parent team is removed' do + create(:team_member, team: team, user: user) + + expect do + perform_enqueued_jobs { team.destroy! } + end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1) + end + + it 'invalidates saved filter snapshots when the parent team is removed' do + create(:conversation, account: account, team: team) + + expect do + perform_enqueued_jobs { team.destroy! } + end.to change { store.conversation_version(account.id) }.by(1) + end + end end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index cb55dba61..8272b5925 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -7,6 +7,31 @@ RSpec.describe Team do it { is_expected.to have_many(:team_members) } end + describe 'name normalization' do + let(:account) { create(:account) } + + it 'downcases the name' do + team = create(:team, account: account, name: 'Customer Support') + expect(team.name).to eq('customer support') + end + + it 'strips control characters and surrounding whitespace' do + team = create(:team, account: account, name: " Sales\n") + expect(team.name).to eq('sales') + end + + it 'removes control characters embedded within the name' do + team = create(:team, account: account, name: "su\npport") + expect(team.name).to eq('support') + end + + it 'is invalid when the name reduces to blank after sanitization' do + team = build(:team, account: account, name: "\t\n ") + expect(team).not_to be_valid + expect(team.errors[:name]).to include(I18n.t('errors.validations.presence')) + end + end + describe '#add_members' do let(:team) { FactoryBot.create(:team) } diff --git a/spec/requests/api/v1/accounts/data_imports_spec.rb b/spec/requests/api/v1/accounts/data_imports_spec.rb new file mode 100644 index 000000000..82a298ef7 --- /dev/null +++ b/spec/requests/api/v1/accounts/data_imports_spec.rb @@ -0,0 +1,438 @@ +require 'rails_helper' + +RSpec.describe 'Data Imports API', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:validator) { instance_double(DataImports::Intercom::CredentialsValidator, perform: { 'contacts' => 12, 'conversations' => 8 }) } + + before do + account.enable_features!('data_import') + allow(DataImports::Intercom::CredentialsValidator).to receive(:new).and_return(validator) + end + + describe 'POST /api/v1/accounts/:account_id/data_imports/validate_source' do + it 'validates the selected Intercom source and returns discovered totals' do + post validate_source_api_v1_account_data_imports_url(account_id: account.id), + params: { + source_provider: 'intercom', access_token: 'intercom-token', import_types: %w[contacts conversations] + }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq('valid' => true, 'totals' => { 'contacts' => 12, 'conversations' => 8 }) + end + + it 'returns a safe validation error' do + allow(validator).to receive(:perform).and_raise(DataImports::Intercom::Client::AuthenticationError, 'provider response') + + post validate_source_api_v1_account_data_imports_url(account_id: account.id), + params: { source_provider: 'intercom', access_token: 'invalid', import_types: %w[contacts] }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body).to eq( + 'valid' => false, + 'message' => 'We could not validate this Intercom access key. Check the key and its permissions.' + ) + end + end + + describe 'POST /api/v1/accounts/:account_id/data_imports' do + it 'returns unauthorized and does not enqueue imports when data import is disabled' do + account.disable_features!('data_import') + + expect do + post api_v1_account_data_imports_url(account_id: account.id), + params: { + name: 'Migration run', source_provider: 'intercom', access_token: 'intercom-token', + import_types: %w[contacts conversations] + }, + headers: admin.create_new_auth_token, + as: :json + end.not_to have_enqueued_job(DataImports::Intercom::ImportJob) + + expect(response).to have_http_status(:unauthorized) + expect(account.data_imports).to be_empty + end + + it 'creates and enqueues an Intercom import', :aggregate_failures do + expect do + post api_v1_account_data_imports_url(account_id: account.id), + params: { + name: 'Migration run', source_provider: 'intercom', access_token: 'intercom-token', + import_types: %w[contacts conversations] + }, + headers: admin.create_new_auth_token, + as: :json + end.to have_enqueued_job(DataImports::Intercom::ImportJob) + + expect(response).to have_http_status(:ok) + data_import = account.data_imports.last + expect(data_import).to have_attributes( + name: 'Migration run', + data_type: 'intercom', + source_type: 'api', + source_provider: 'intercom', + initiated_by_id: admin.id + ) + expect(data_import.access_token).to eq('intercom-token') + expect(data_import.import_types).to eq(%w[contacts conversations]) + expect(data_import.stats).to include( + 'contacts' => include('total' => 12), + 'conversations' => include('total' => 8) + ) + expect(response.parsed_body['source_provider']).to eq('intercom') + expect(response.parsed_body).not_to have_key('access_token') + end + + it 'rejects creation while another Intercom import is active' do + active_import = create( + :data_import, :intercom, + account: account, + status: :processing + ) + + expect do + post api_v1_account_data_imports_url(account_id: account.id), + params: { + name: 'Second run', source_provider: 'intercom', access_token: 'intercom-token', + import_types: %w[contacts conversations] + }, + headers: admin.create_new_auth_token, + as: :json + end.not_to have_enqueued_job(DataImports::Intercom::ImportJob) + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['message']).to eq('Another data import is already in progress.') + expect(account.data_imports.where(data_type: 'intercom', source_provider: 'intercom').count).to eq(1) + expect(active_import.reload).to be_processing + end + + it 'rejects unsupported import types instead of silently importing everything' do + allow(validator).to receive(:perform).and_raise(ArgumentError, 'Unsupported import types: companies') + + expect do + post api_v1_account_data_imports_url(account_id: account.id), + params: { + name: 'Migration run', source_provider: 'intercom', access_token: 'intercom-token', import_types: %w[companies] + }, + headers: admin.create_new_auth_token, + as: :json + end.not_to have_enqueued_job(DataImports::Intercom::ImportJob) + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['message']).to eq('Unsupported import types: companies') + expect(account.data_imports).to be_empty + end + end + + describe 'POST /api/v1/accounts/:account_id/data_imports/:id/start' do + let(:data_import) { create(:data_import, :intercom, account: account) } + + it 'restarts abandoned imports' do + data_import.update!( + status: :abandoned, + abandoned_at: 1.hour.ago, + source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'previous-run' } + ) + data_import.import_errors.create!( + error_code: 'StandardError', + message: 'old run error', + details: { kind: 'run_error' } + ) + data_import.import_errors.create!( + error_code: DataImports::Intercom::Importer::ALREADY_IMPORTED_ERROR_CODE, + message: 'old skip log', + details: { kind: 'skipped' } + ) + + expect do + post start_api_v1_account_data_import_url(account_id: account.id, id: data_import.id), + headers: admin.create_new_auth_token, + as: :json + end.to have_enqueued_job(DataImports::Intercom::ImportJob).with(data_import, a_kind_of(String)) + + expect(response).to have_http_status(:ok) + expect(data_import.reload).to be_pending + expect(data_import.abandoned_at).to be_nil + expect(data_import.started_at).to be_nil + expect(data_import.active_intercom_import_run_id).not_to eq('previous-run') + expect(data_import.import_errors.pluck(:error_code)).to eq([DataImports::Intercom::Importer::ALREADY_IMPORTED_ERROR_CODE]) + end + + it 'does not enqueue duplicate jobs for active imports' do + data_import.update!(status: :processing) + + expect do + post start_api_v1_account_data_import_url(account_id: account.id, id: data_import.id), + headers: admin.create_new_auth_token, + as: :json + end.not_to have_enqueued_job(DataImports::Intercom::ImportJob) + + expect(response).to have_http_status(:ok) + expect(data_import.reload).to be_processing + end + + it 'returns the active Intercom import instead of restarting another import' do + data_import.update!(status: :abandoned, abandoned_at: 1.hour.ago) + active_import = create( + :data_import, :intercom, + account: account, + status: :processing + ) + + expect do + post start_api_v1_account_data_import_url(account_id: account.id, id: data_import.id), + headers: admin.create_new_auth_token, + as: :json + end.not_to have_enqueued_job(DataImports::Intercom::ImportJob) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['id']).to eq(active_import.id) + expect(data_import.reload).to be_abandoned + end + + it 'does not restart imports when the stored access key is unavailable' do + data_import.update!(status: :abandoned, abandoned_at: 1.hour.ago, access_token: nil) + + expect do + post start_api_v1_account_data_import_url(account_id: account.id, id: data_import.id), + headers: admin.create_new_auth_token, + as: :json + end.not_to have_enqueued_job(DataImports::Intercom::ImportJob) + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['message']).to eq('The Intercom access key for this import is unavailable.') + expect(data_import.reload).to be_abandoned + end + end + + describe 'POST /api/v1/accounts/:account_id/data_imports/:id/abandon' do + let(:data_import) { create(:data_import, :intercom, account: account) } + + it 'abandons active imports' do + data_import.update!(status: :processing) + + post abandon_api_v1_account_data_import_url(account_id: account.id, id: data_import.id), + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:ok) + expect(data_import.reload).to be_abandoned + expect(data_import.abandoned_at).to be_present + end + + it 'does not rewrite completed imports as abandoned' do + data_import.update!(status: :completed, completed_at: 1.hour.ago) + + post abandon_api_v1_account_data_import_url(account_id: account.id, id: data_import.id), + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:ok) + expect(data_import.reload).to be_completed + expect(data_import.abandoned_at).to be_nil + end + + it 'does not abandon legacy contact imports' do + legacy_import = create(:data_import, account: account, status: :processing) + + post abandon_api_v1_account_data_import_url(account_id: account.id, id: legacy_import.id), + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:ok) + expect(legacy_import.reload).to be_processing + expect(legacy_import.abandoned_at).to be_nil + end + end + + describe 'GET /api/v1/accounts/:account_id/data_imports/:id' do + let(:data_import) do + create( + :data_import, :intercom, + account: account, + name: 'July Intercom migration', + initiated_by: admin + ) + end + + it 'returns import details with recent errors' do + data_import.import_errors.create!( + error_code: 'Intercom::RateLimited', + message: 'Rate limited', + source_object_type: 'conversation', + source_object_id: 'conversation_1' + ) + data_import.import_errors.create!( + error_code: 'DataImports::Intercom::SkippedMessage', + message: 'Skipped blank message', + source_object_type: 'message', + source_object_id: 'conversation:conversation_1:part:blank_part', + details: { kind: 'skipped', reason: 'blank_or_unsupported_intercom_part' } + ) + + get api_v1_account_data_import_url(account_id: account.id, id: data_import.id), + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to include( + 'id' => data_import.id, + 'name' => 'July Intercom migration', + 'source_provider' => 'intercom', + 'import_errors_count' => 1, + 'skip_logs_count' => 1 + ) + expect(response.parsed_body['import_errors'].first).to include( + 'error_code' => 'Intercom::RateLimited', + 'message' => 'Rate limited', + 'source_object_type' => 'conversation', + 'source_object_id' => 'conversation_1' + ) + expect(response.parsed_body['skip_logs'].first).to include( + 'kind' => 'skipped', + 'error_code' => 'DataImports::Intercom::SkippedMessage', + 'source_object_type' => 'message', + 'source_object_id' => 'conversation:conversation_1:part:blank_part' + ) + end + + it 'returns the latest five skip logs' do + 16.times do |index| + data_import.import_errors.create!( + error_code: 'DataImports::Intercom::AlreadyImported', + message: 'Already imported in a previous import.', + source_object_type: 'message', + source_object_id: "message_#{index}", + details: { kind: 'skipped', reason: 'already_imported' }, + created_at: Time.zone.at(index) + ) + end + + get api_v1_account_data_import_url(account_id: account.id, id: data_import.id), + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['skip_logs'].pluck('source_object_id')).to eq(%w[message_15 message_14 message_13 message_12 message_11]) + expect(response.parsed_body).not_to have_key('skip_logs_pagination') + end + + it 'filters skip logs by source object type with counts for each type' do + 3.times do |index| + data_import.import_errors.create!( + error_code: 'DataImports::Intercom::AlreadyImported', + message: 'Already imported in a previous import.', + source_object_type: 'contact', + source_object_id: "contact_#{index}", + details: { kind: 'skipped', reason: 'already_imported' } + ) + end + 2.times do |index| + data_import.import_errors.create!( + error_code: 'DataImports::Intercom::AlreadyImported', + message: 'Already imported in a previous import.', + source_object_type: 'message', + source_object_id: "message_#{index}", + details: { kind: 'skipped', reason: 'already_imported' } + ) + end + + get api_v1_account_data_import_url(account_id: account.id, id: data_import.id, skip_logs_type: 'contact'), + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['skip_logs'].pluck('source_object_type').uniq).to eq(['contact']) + expect(response.parsed_body['skip_logs_filters']).to include( + 'selected_source_object_type' => 'contact', + 'counts_by_type' => include('contact' => 3, 'message' => 2) + ) + end + + it 'returns the latest five error logs' do + 16.times do |index| + data_import.import_errors.create!( + error_code: 'Intercom::RateLimited', + message: 'Rate limited', + source_object_type: 'conversation', + source_object_id: "conversation_#{index}", + created_at: Time.zone.at(index) + ) + end + + get api_v1_account_data_import_url(account_id: account.id, id: data_import.id), + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['import_errors'].pluck('source_object_id')).to eq( + %w[conversation_15 conversation_14 conversation_13 conversation_12 conversation_11] + ) + expect(response.parsed_body).not_to have_key('import_errors_pagination') + end + end + + describe 'GET /api/v1/accounts/:account_id/data_imports/:id/error_logs.csv' do + let(:data_import) do + create( + :data_import, :intercom, + account: account, + initiated_by: admin + ) + end + + it 'downloads all error logs as CSV' do + 6.times do |index| + data_import.import_errors.create!( + error_code: 'Intercom::RateLimited', + message: 'Rate limited', + source_object_type: 'conversation', + source_object_id: "conversation_#{index}", + details: { kind: 'run_error' } + ) + end + + get error_logs_api_v1_account_data_import_url(account_id: account.id, id: data_import.id, format: :csv), + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('text/csv') + expect(response.body).to include('source_object_type,source_object_id') + expect(response.body).to include('conversation,conversation_0,Intercom::RateLimited,Rate limited') + expect(response.body).to include('conversation,conversation_5,Intercom::RateLimited,Rate limited') + expect(response.body.lines.size).to eq(7) + end + end + + describe 'GET /api/v1/accounts/:account_id/data_imports/:id/skip_logs.csv' do + let(:data_import) do + create( + :data_import, :intercom, + account: account, + initiated_by: admin + ) + end + + it 'downloads skip logs as CSV' do + data_import.import_errors.create!( + error_code: 'DataImports::Intercom::SkippedMessage', + message: 'Skipped blank message', + source_object_type: 'message', + source_object_id: 'conversation:conversation_1:part:blank_part', + details: { kind: 'skipped', reason: 'blank_or_unsupported_intercom_part' } + ) + + get skip_logs_api_v1_account_data_import_url(account_id: account.id, id: data_import.id, format: :csv), + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('text/csv') + expect(response.body).to include('source_object_type,source_object_id') + expect(response.body).to include('message,conversation:conversation_1:part:blank_part') + end + end +end diff --git a/spec/services/auto_assignment/assignment_service_spec.rb b/spec/services/auto_assignment/assignment_service_spec.rb index eb6ebf060..44bbf89b1 100644 --- a/spec/services/auto_assignment/assignment_service_spec.rb +++ b/spec/services/auto_assignment/assignment_service_spec.rb @@ -192,6 +192,73 @@ RSpec.describe AutoAssignment::AssignmentService do end end + context 'with age-based exclusion' do + let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) } + + before do + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' }) + + round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector) + allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector) + allow(round_robin_selector).to receive(:select_agent).and_return(agent) + + allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter) + allow(rate_limiter).to receive(:within_limit?).and_return(true) + allow(rate_limiter).to receive(:track_assignment) + end + + it 'skips conversations inactive beyond the policy threshold' do + assignment_policy.update!(exclude_older_than_hours: 24) + old_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago) + recent_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago) + + assigned_count = service.perform_bulk_assignment(limit: 10) + + expect(assigned_count).to eq(1) + expect(old_conversation.reload.assignee).to be_nil + expect(recent_conversation.reload.assignee).to eq(agent) + end + + it 'assigns reopened conversations created long ago but recently active' do + assignment_policy.update!(exclude_older_than_hours: 24) + reopened_conversation = create(:conversation, inbox: inbox, assignee: nil, + created_at: 30.days.ago, last_activity_at: 1.hour.ago) + + assigned_count = service.perform_bulk_assignment(limit: 10) + + expect(assigned_count).to eq(1) + expect(reopened_conversation.reload.assignee).to eq(agent) + end + + it 'assigns conversations regardless of age when threshold is nil' do + assignment_policy.update!(exclude_older_than_hours: nil) + old_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 30.days.ago) + + assigned_count = service.perform_bulk_assignment(limit: 10) + + expect(assigned_count).to eq(1) + expect(old_conversation.reload.assignee).to eq(agent) + end + + context 'when the inbox has no assignment policy' do + before do + inbox.inbox_assignment_policy.destroy! + inbox.reload + end + + it 'falls back to the default threshold and skips stale conversations' do + stale_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 8.days.ago) + recent_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 6.days.ago) + + assigned_count = service.perform_bulk_assignment(limit: 10) + + expect(assigned_count).to eq(1) + expect(stale_conversation.reload.assignee).to be_nil + expect(recent_conversation.reload.assignee).to eq(agent) + end + end + end + context 'with fair distribution' do before do create(:inbox_member, inbox: inbox, user: agent2) diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb index fa054b330..eecbaec2e 100644 --- a/spec/services/conversations/filter_service_spec.rb +++ b/spec/services/conversations/filter_service_spec.rb @@ -231,6 +231,24 @@ describe Conversations::FilterService do expect(result[:count][:all_count]).to be 2 end + it 'filters conversations by display_id substring' do + conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1) + create(:conversation, account: account, inbox: inbox, assignee: user_1) + + params[:payload] = [{ + attribute_key: 'display_id', + filter_operator: 'contains', + values: [conversation.display_id.to_s], + query_operator: nil, + custom_attribute_type: '' + }.with_indifferent_access] + + result = filter_service.new(params, user_1, account).perform + + expect(result[:count][:all_count]).to eq(1) + expect(result[:conversations].pluck(:id)).to contain_exactly(conversation.id) + end + it 'filters items with does not contain filter operator with values being an array' do params[:payload] = [{ attribute_key: 'browser_language', diff --git a/spec/services/conversations/unread_counts/counter_spec.rb b/spec/services/conversations/unread_counts/counter_spec.rb index 723f2d437..50efd7d43 100644 --- a/spec/services/conversations/unread_counts/counter_spec.rb +++ b/spec/services/conversations/unread_counts/counter_spec.rb @@ -95,4 +95,22 @@ RSpec.describe Conversations::UnreadCounts::Counter do teams: { visible_team.id.to_s => 1 } ) end + + it 'merges filtered counts when the filtered count feature is enabled' do + account.enable_features!(:unread_count_for_filters) + filtered_counter = instance_double( + Conversations::UnreadCounts::FilteredCounter, + perform: { mentions_count: 1, participating_count: 2, unattended_count: 3, folders: { '4' => 5 } } + ) + allow(Conversations::UnreadCounts::FilteredCounter).to receive(:new).and_return(filtered_counter) + + result = described_class.new(account: account, user: agent).perform + + expect(result).to include( + mentions_count: 1, + participating_count: 2, + unattended_count: 3, + folders: { '4' => 5 } + ) + end end diff --git a/spec/services/conversations/unread_counts/filtered_count_instrumentation_spec.rb b/spec/services/conversations/unread_counts/filtered_count_instrumentation_spec.rb new file mode 100644 index 000000000..4fb6c6d36 --- /dev/null +++ b/spec/services/conversations/unread_counts/filtered_count_instrumentation_spec.rb @@ -0,0 +1,137 @@ +require 'rails_helper' + +RSpec.describe Conversations::UnreadCounts::FilteredCountInstrumentation do + let(:new_relic_agent) do + Class.new do + def self.record_custom_event(*) end + def self.record_metric(*) end + end + end + + before do + stub_const('NewRelic::Agent', new_relic_agent) + allow(new_relic_agent).to receive(:record_custom_event) + allow(new_relic_agent).to receive(:record_metric) + end + + describe '.observe' do + it 'records duration metrics without custom events around successful operations' do + result = described_class.observe(:counter_perform, account_id: 1, snapshot_scope: :built_in_filter) { 'ok' } + + expect(result).to eq('ok') + expect(new_relic_agent).not_to have_received(:record_custom_event) + expect(new_relic_agent).to have_received(:record_metric).with( + 'Custom/Conversations/UnreadCounts/Filtered/counter_perform/duration_ms', + kind_of(Float) + ) + end + + it 'records failed operations and re-raises the original error' do + error = StandardError.new('boom') + + expect do + described_class.observe(:snapshot_build, account_id: 1) { raise error } + end.to raise_error(error) + expect(new_relic_agent).not_to have_received(:record_custom_event) + expect(new_relic_agent).to have_received(:record_metric).with( + 'Custom/Conversations/UnreadCounts/Filtered/snapshot_build/duration_ms', + kind_of(Float) + ) + end + end + + describe '.increment' do + it 'records count metrics without custom events for aggregated read-path operations' do + described_class.increment(:snapshot_state, account_id: 1, snapshot_status: :fresh) + + expect(new_relic_agent).not_to have_received(:record_custom_event) + expect(new_relic_agent).to have_received(:record_metric).with( + 'Custom/Conversations/UnreadCounts/Filtered/snapshot_state/count', + 1 + ) + end + + it 'keeps custom events for invalidation signals' do + described_class.increment(:invalidation, account_id: 1, invalidation_scope: :conversation) + + expect(new_relic_agent).to have_received(:record_custom_event).with( + 'FilteredUnreadCounts', + hash_including( + account_id: 1, + invalidation_scope: 'conversation', + operation: 'invalidation' + ) + ) + expect(new_relic_agent).to have_received(:record_metric).with( + 'Custom/Conversations/UnreadCounts/Filtered/invalidation/count', + 1 + ) + end + + it 'does not raise when New Relic is unavailable' do + allow(described_class).to receive(:new_relic_agent).and_return(nil) + + expect { described_class.increment(:snapshot_state, account_id: 1) }.not_to raise_error + end + end + + describe '.summarize_request' do + it 'records one custom event with aggregated request counters' do + result = described_class.summarize_request(account_id: 1) do + described_class.increment(:snapshot_state, account_id: 1, snapshot_scope: :built_in_filter, snapshot_status: :fresh) + described_class.increment(:snapshot_state, account_id: 1, snapshot_scope: :filter, snapshot_status: :missing) + described_class.increment(:refresh_claim, account_id: 1, snapshot_scope: :filter, claimed: true) + described_class.increment(:refresh_claim, account_id: 1, snapshot_scope: :filter, claimed: false) + described_class.increment(:build_lock, account_id: 1, snapshot_scope: :filter, acquired: true) + described_class.observe(:snapshot_build, account_id: 1, snapshot_scope: :filter) { 'built' } + + 'ok' + end + + expect(result).to eq('ok') + expect(new_relic_agent).to have_received(:record_custom_event).once.with( + 'FilteredUnreadCounts', + hash_including( + account_id: 1, + build_lock_acquired_count: 1, + duration_ms: kind_of(Float), + filter_build_lock_acquired_count: 1, + filter_refresh_claimed_count: 1, + filter_refresh_skipped_count: 1, + filter_snapshot_build_success_count: 1, + filter_snapshot_count: 1, + operation: 'request_summary', + refresh_claimed_count: 1, + refresh_skipped_count: 1, + snapshot_build_success_count: 1, + snapshot_fresh_count: 1, + snapshot_missing_count: 1, + snapshot_total_count: 2, + status: 'success' + ) + ) + expect(new_relic_agent).to have_received(:record_metric).with( + 'Custom/Conversations/UnreadCounts/Filtered/api_response/duration_ms', + kind_of(Float) + ) + end + + it 'records summary errors and re-raises the original error' do + error = StandardError.new('boom') + + expect do + described_class.summarize_request(account_id: 1) { raise error } + end.to raise_error(error) + + expect(new_relic_agent).to have_received(:record_custom_event).with( + 'FilteredUnreadCounts', + hash_including( + account_id: 1, + error_class: 'StandardError', + operation: 'request_summary', + status: 'error' + ) + ) + end + end +end diff --git a/spec/services/conversations/unread_counts/filtered_count_invalidator_spec.rb b/spec/services/conversations/unread_counts/filtered_count_invalidator_spec.rb new file mode 100644 index 000000000..e0324c4b5 --- /dev/null +++ b/spec/services/conversations/unread_counts/filtered_count_invalidator_spec.rb @@ -0,0 +1,244 @@ +require 'rails_helper' + +RSpec.describe Conversations::UnreadCounts::FilteredCountInvalidator do + subject(:invalidator) { described_class.new(account) } + + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:other_user) { create(:user, account: account) } + let(:filter_id) { 123 } + let(:store) { Conversations::UnreadCounts::FilteredCountStore } + + after do + redis_keys.each { |key| Redis::Alfred.delete(key) } + end + + describe '#conversation_changed!' do + it 'bumps the account conversation version when the feature is enabled' do + account.enable_features!(:unread_count_for_filters) + + expect { invalidator.conversation_changed! }.to change { store.conversation_version(account.id) }.by(1) + end + + it 'records invalidation instrumentation when the feature is enabled' do + account.enable_features!(:unread_count_for_filters) + allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:increment) + + invalidator.conversation_changed! + + expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:increment).with( + :invalidation, + account_id: account.id, + invalidation_scope: :conversation, + reason: :conversation_changed, + version: 1 + ) + end + + it 'does not write Redis keys when the feature is disabled' do + expect { invalidator.conversation_changed! }.not_to(change { store.conversation_version(account.id) }) + end + end + + describe '#user_visibility_changed!' do + it 'bumps the user built-in filter version' do + account.enable_features!(:unread_count_for_filters) + + expect do + invalidator.user_visibility_changed!(user_id: user.id) + end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1) + end + end + + describe '#users_visibility_changed!' do + it 'pipelines built-in filter version bumps for multiple users' do + account.enable_features!(:unread_count_for_filters) + user_ids = [user.id, other_user.id] + allow(Redis::Alfred).to receive(:pipelined).and_call_original + + expect do + invalidator.users_visibility_changed!(user_ids: user_ids + [user.id, nil]) + end.to change { built_in_filter_version_for(user) }.by(1) + .and change { built_in_filter_version_for(other_user) }.by(1) + + expect(Redis::Alfred).to have_received(:pipelined).once + end + + it 'does not write Redis keys when no user ids are present' do + account.enable_features!(:unread_count_for_filters) + + expect(invalidator.users_visibility_changed!(user_ids: [nil, ''])).to be(false) + end + end + + describe '#custom_filter_created!' do + it 'bumps the folder index and saved filter versions for conversation filters' do + account.enable_features!(:unread_count_for_filters) + filter_version = store.filter_version(account_id: account.id, filter_id: filter_id) + + expect do + invalidator.custom_filter_created!(conversation_filter) + end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1) + expect(store.filter_version(account_id: account.id, filter_id: filter_id)).to eq(filter_version + 1) + end + + it 'ignores non-conversation filters' do + account.enable_features!(:unread_count_for_filters) + + expect do + invalidator.custom_filter_created!(conversation_filter(is_conversation: false)) + end.not_to(change { store.folder_index_version(account_id: account.id, user_id: user.id) }) + end + end + + describe '#custom_filter_updated!' do + it 'bumps only the filter version when the query changes' do + account.enable_features!(:unread_count_for_filters) + filter = conversation_filter(previous_changes: { 'query' => [{ status: 'open' }, { status: 'resolved' }] }) + folder_index_version = store.folder_index_version(account_id: account.id, user_id: user.id) + + expect do + invalidator.custom_filter_updated!(filter) + end.to change { store.filter_version(account_id: account.id, filter_id: filter_id) }.by(1) + expect(store.folder_index_version(account_id: account.id, user_id: user.id)).to eq(folder_index_version) + end + + it 'ignores name-only updates' do + account.enable_features!(:unread_count_for_filters) + filter = conversation_filter(previous_changes: { 'name' => %w[Open Resolved] }) + + expect do + invalidator.custom_filter_updated!(filter) + end.not_to(change { store.filter_version(account_id: account.id, filter_id: filter_id) }) + end + + it 'bumps versions and deletes the saved count when the filter moves away from conversations' do + account.enable_features!(:unread_count_for_filters) + filter = conversation_filter( + is_conversation: false, + previous_changes: { 'filter_type' => %w[conversation contact] } + ) + store.write_filter_count!( + account_id: account.id, + filter_id: filter_id, + user_id: user.id, + count: 4, + account_version: 0, + filter_version: 0, + owner_built_in_filter_version: 0 + ) + filter_version = store.filter_version(account_id: account.id, filter_id: filter_id) + + expect do + invalidator.custom_filter_updated!(filter) + end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1) + expect(store.filter_version(account_id: account.id, filter_id: filter_id)).to eq(filter_version + 1) + expect(store.filter_count(account_id: account.id, filter_id: filter_id)).to be_nil + end + end + + describe '#custom_filter_destroyed!' do + it 'bumps the folder index version and deletes the saved count' do + account.enable_features!(:unread_count_for_filters) + store.write_filter_count!( + account_id: account.id, + filter_id: filter_id, + user_id: user.id, + count: 2, + account_version: 0, + filter_version: 0, + owner_built_in_filter_version: 0 + ) + + expect do + invalidator.custom_filter_destroyed!(conversation_filter) + end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1) + expect(store.filter_count(account_id: account.id, filter_id: filter_id)).to be_nil + end + end + + describe '#custom_attribute_definition_changed!' do + it 'bumps affected conversation saved filter versions' do + account.enable_features!(:unread_count_for_filters) + definition = create(:custom_attribute_definition, account: account, attribute_key: 'plan', attribute_model: 'conversation_attribute') + matching_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('plan')) + blank_type_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('plan', '')) + contact_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('plan', 'contact_attribute')) + other_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('tier')) + versions = filter_versions(matching_filter, blank_type_filter, contact_filter, other_filter) + + invalidator.custom_attribute_definition_changed!(definition) + + expect(store.filter_version(account_id: account.id, filter_id: matching_filter.id)).to eq(versions[matching_filter.id] + 1) + expect(store.filter_version(account_id: account.id, filter_id: blank_type_filter.id)).to eq(versions[blank_type_filter.id] + 1) + expect(store.filter_version(account_id: account.id, filter_id: contact_filter.id)).to eq(versions[contact_filter.id]) + expect(store.filter_version(account_id: account.id, filter_id: other_filter.id)).to eq(versions[other_filter.id]) + end + + it 'bumps filters referencing the previous attribute key when the key changes' do + definition = create(:custom_attribute_definition, account: account, attribute_key: 'plan', attribute_model: 'conversation_attribute') + matching_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('plan')) + version = store.filter_version(account_id: account.id, filter_id: matching_filter.id) + + definition.update!(attribute_key: 'new_plan') + account.enable_features!(:unread_count_for_filters) + + expect do + invalidator.custom_attribute_definition_changed!(definition) + end.to change { store.filter_version(account_id: account.id, filter_id: matching_filter.id) }.from(version).to(version + 1) + end + end + + def conversation_filter(is_conversation: true, previous_changes: {}) + instance_double( + CustomFilter, + id: filter_id, + user_id: user.id, + conversation?: is_conversation, + previous_changes: previous_changes + ) + end + + def custom_attribute_query(attribute_key, custom_attribute_type = 'conversation_attribute') + { + payload: [ + { + attribute_key: attribute_key, + filter_operator: 'equal_to', + values: ['gold'], + custom_attribute_type: custom_attribute_type + } + ] + } + end + + def filter_versions(*custom_filters) + custom_filters.to_h { |custom_filter| [custom_filter.id, store.filter_version(account_id: account.id, filter_id: custom_filter.id)] } + end + + def built_in_filter_version_for(user) + store.built_in_filter_version(account_id: account.id, user_id: user.id) + end + + def redis_keys + base_redis_keys + custom_filter_version_keys + end + + def base_redis_keys + [ + store.conversation_version_key(account.id), + *built_in_filter_version_keys, + store.folder_index_version_key(account.id, user.id), + store.filter_version_key(account.id, filter_id), + store.filter_count_key(account.id, filter_id) + ] + end + + def built_in_filter_version_keys + [user.id, other_user.id].map { |user_id| store.built_in_filter_version_key(account.id, user_id) } + end + + def custom_filter_version_keys + CustomFilter.where(account_id: account.id).pluck(:id).map { |id| store.filter_version_key(account.id, id) } + end +end diff --git a/spec/services/conversations/unread_counts/filtered_count_store_spec.rb b/spec/services/conversations/unread_counts/filtered_count_store_spec.rb new file mode 100644 index 000000000..3a7cd52d8 --- /dev/null +++ b/spec/services/conversations/unread_counts/filtered_count_store_spec.rb @@ -0,0 +1,279 @@ +require 'rails_helper' + +RSpec.describe Conversations::UnreadCounts::FilteredCountStore do + let(:account_id) { 1 } + let(:user_id) { 2 } + let(:filter_id) { 3 } + let(:built_at) { Time.zone.parse('2026-06-29 10:00:00 UTC') } + + after do + redis_keys.each { |key| Redis::Alfred.delete(key) } + end + + describe 'key builders' do + it 'builds V2 keys for built-in filters, folder indexes, and saved filters' do + expect(described_class.conversation_version_key(account_id)).to eq( + 'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::CONVERSATION_VERSION' + ) + expect(described_class.built_in_filter_version_key(account_id, user_id)).to eq( + 'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::USER::2::BUILT_IN_FILTER_VERSION' + ) + expect(described_class.built_in_filter_counts_key(account_id, user_id)).to eq( + 'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::USER::2::BUILT_IN_FILTER_COUNTS' + ) + expect(described_class.folder_index_key(account_id, user_id)).to eq( + 'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::USER::2::FOLDER_INDEX' + ) + expect(described_class.filter_count_key(account_id, filter_id)).to eq( + 'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::FILTER::3::COUNT' + ) + end + end + + describe 'version metadata' do + it 'defaults missing version keys to zero' do + expect(described_class.conversation_version(account_id)).to eq(0) + expect(described_class.built_in_filter_version(account_id: account_id, user_id: user_id)).to eq(0) + expect(described_class.folder_index_version(account_id: account_id, user_id: user_id)).to eq(0) + expect(described_class.filter_version(account_id: account_id, filter_id: filter_id)).to eq(0) + end + + it 'increments independent version keys' do + expect(described_class.bump_conversation_version!(account_id)).to eq(1) + expect(described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)).to eq(1) + expect(described_class.bump_folder_index_version!(account_id: account_id, user_id: user_id)).to eq(1) + expect(described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id)).to eq(1) + end + + it 'increments and expires version keys in one Redis transaction' do + key = described_class.conversation_version_key(account_id) + connection = instance_double(Redis) + transaction = instance_double(Redis::MultiConnection) + + allow(Redis::Alfred).to receive(:with).and_yield(connection) + expect(connection).to receive(:multi).and_yield(transaction).and_return([1, true]) + expect(transaction).to receive(:incr).with(key) + expect(transaction).to receive(:expire).with(key, Conversations::UnreadCounts::FILTERED_COUNT_VERSION_TTL) + + expect(described_class.bump_conversation_version!(account_id)).to eq(1) + end + + it 'expires version keys after bumping them' do + described_class.bump_conversation_version!(account_id) + described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id) + described_class.bump_folder_index_version!(account_id: account_id, user_id: user_id) + described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id) + + version_keys.each do |key| + expect(ttl_for(key)).to be_within(5).of(Conversations::UnreadCounts::FILTERED_COUNT_VERSION_TTL) + end + end + end + + describe 'built-in filter count snapshots' do + it 'round-trips counts and classifies fresh, stale, expired, and missing snapshots' do + account_version = described_class.bump_conversation_version!(account_id) + built_in_filter_version = described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id) + + described_class.write_built_in_filter_counts!( + account_id: account_id, + user_id: user_id, + account_version: account_version, + built_in_filter_version: built_in_filter_version, + built_at: built_at, + counts: { mentions_count: 3, participating_count: 4, unattended_count: 5 }, + meta: { permission_mode: 'base' } + ) + + snapshot = described_class.built_in_filter_counts(account_id: account_id, user_id: user_id) + expect(snapshot[:counts]).to eq(mentions_count: 3, participating_count: 4, unattended_count: 5) + expect(snapshot[:meta]).to eq(permission_mode: 'base') + expect(ttl_for(described_class.built_in_filter_counts_key(account_id, user_id))).to be_within(5).of( + Conversations::UnreadCounts::FILTERED_COUNT_REDIS_TTL + ) + + expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 1.minute)).to be_fresh + + described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id) + expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 2.minutes)).to be_stale + expect( + described_class.built_in_filter_counts_state( + account_id: account_id, + user_id: user_id, + now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_FRESH_TTL + + Conversations::UnreadCounts::FILTERED_COUNT_STALE_WINDOW + 1.second + ) + ).to be_expired + + Redis::Alfred.delete(described_class.built_in_filter_counts_key(account_id, user_id)) + expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id)).to be_missing + end + end + + describe 'folder index snapshots' do + it 'round-trips folder ids and classifies freshness against the folder index version' do + folder_index_version = described_class.bump_folder_index_version!(account_id: account_id, user_id: user_id) + + described_class.write_folder_index!( + account_id: account_id, + user_id: user_id, + folder_index_version: folder_index_version, + built_at: built_at, + filter_ids: %w[10 11] + ) + + expect(described_class.folder_index(account_id: account_id, user_id: user_id)[:filter_ids]).to eq([10, 11]) + expect(described_class.folder_index_state(account_id: account_id, user_id: user_id, now: built_at + 1.minute)).to be_fresh + + described_class.bump_folder_index_version!(account_id: account_id, user_id: user_id) + expect(described_class.folder_index_state(account_id: account_id, user_id: user_id, now: built_at + 2.minutes)).to be_stale + end + end + + describe 'saved filter count snapshots' do + it 'round-trips counts and uses account, filter, and owner built-in filter versions for freshness' do + account_version = described_class.bump_conversation_version!(account_id) + filter_version = described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id) + owner_built_in_filter_version = described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id) + + described_class.write_filter_count!( + account_id: account_id, + filter_id: filter_id, + user_id: user_id, + count: 7, + account_version: account_version, + filter_version: filter_version, + owner_built_in_filter_version: owner_built_in_filter_version, + built_at: built_at, + meta: { status: 'ok', timed_out: false, invalid_filter: false } + ) + + snapshot = described_class.filter_count(account_id: account_id, filter_id: filter_id) + expect(snapshot[:count]).to eq(7) + expect(snapshot[:meta]).to eq(status: 'ok', timed_out: false, invalid_filter: false) + expect(described_class.filter_count_state(account_id: account_id, filter_id: filter_id, now: built_at + 1.minute)).to be_fresh + + described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id) + expect(described_class.filter_count_state(account_id: account_id, filter_id: filter_id, now: built_at + 2.minutes)).to be_stale + + described_class.delete_filter_count!(account_id: account_id, filter_id: filter_id) + expect(described_class.filter_count(account_id: account_id, filter_id: filter_id)).to be_nil + end + + it 'uses caller-provided versions when classifying snapshots' do + account_version = described_class.bump_conversation_version!(account_id) + filter_version = described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id) + owner_built_in_filter_version = described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id) + + described_class.write_filter_count!( + account_id: account_id, + filter_id: filter_id, + user_id: user_id, + count: 7, + account_version: account_version, + filter_version: filter_version, + owner_built_in_filter_version: owner_built_in_filter_version, + built_at: built_at + ) + + versions = { + account_version: account_version, + filter_version: filter_version, + owner_built_in_filter_version: owner_built_in_filter_version + } + + expect(described_class).not_to receive(:conversation_version) + expect(described_class).not_to receive(:filter_version) + expect(described_class).not_to receive(:built_in_filter_version) + + expect( + described_class.filter_count_state( + account_id: account_id, + filter_id: filter_id, + versions: versions, + now: built_at + 1.minute + ) + ).to be_fresh + end + end + + describe 'refresh throttles' do + it 'uses refresh_after and independent throttle keys to suppress duplicate rebuilds' do + described_class.write_built_in_filter_counts!( + account_id: account_id, + user_id: user_id, + account_version: 0, + built_in_filter_version: 0, + built_at: built_at, + counts: {} + ) + + snapshot = described_class.built_in_filter_counts(account_id: account_id, user_id: user_id) + expect( + described_class.refresh_due?( + snapshot, + now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second + ) + ).to be(false) + expect( + described_class.refresh_due?( + snapshot, + now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second + ) + ).to be(true) + + expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(true) + expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(false) + expect(described_class.claim_folder_index_refresh!(account_id: account_id, user_id: user_id)).to be(true) + expect(described_class.claim_filter_refresh!(account_id: account_id, filter_id: filter_id)).to be(true) + end + end + + describe 'Redis access pattern' do + it 'does not scan Redis keys' do + expect(Redis::Alfred).not_to receive(:scan_each) + + described_class.bump_conversation_version!(account_id) + described_class.write_folder_index!(account_id: account_id, user_id: user_id, folder_index_version: 0, filter_ids: [filter_id]) + described_class.folder_index_state(account_id: account_id, user_id: user_id) + described_class.claim_filter_refresh!(account_id: account_id, filter_id: filter_id) + described_class.delete_filter_count!(account_id: account_id, filter_id: filter_id) + end + end + + def ttl_for(key) + Redis::Alfred.ttl(key) + end + + def redis_keys + version_keys + snapshot_keys + lock_and_throttle_keys + end + + def version_keys + [ + described_class.conversation_version_key(account_id), + described_class.built_in_filter_version_key(account_id, user_id), + described_class.folder_index_version_key(account_id, user_id), + described_class.filter_version_key(account_id, filter_id) + ] + end + + def snapshot_keys + [ + described_class.built_in_filter_counts_key(account_id, user_id), + described_class.folder_index_key(account_id, user_id), + described_class.filter_count_key(account_id, filter_id) + ] + end + + def lock_and_throttle_keys + [ + described_class.built_in_filter_build_lock_key(account_id, user_id), + described_class.built_in_filter_refresh_throttle_key(account_id, user_id), + described_class.folder_index_build_lock_key(account_id, user_id), + described_class.folder_index_refresh_throttle_key(account_id, user_id), + described_class.filter_build_lock_key(account_id, filter_id), + described_class.filter_refresh_throttle_key(account_id, filter_id) + ] + end +end diff --git a/spec/services/conversations/unread_counts/filtered_counter_spec.rb b/spec/services/conversations/unread_counts/filtered_counter_spec.rb new file mode 100644 index 000000000..2904d1e4e --- /dev/null +++ b/spec/services/conversations/unread_counts/filtered_counter_spec.rb @@ -0,0 +1,565 @@ +require 'rails_helper' + +RSpec.describe Conversations::UnreadCounts::FilteredCounter do + subject(:counter) { described_class.new(account: account, user: agent, now: now) } + + let(:account) { create(:account) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:visible_inbox) { create(:inbox, account: account) } + let(:hidden_inbox) { create(:inbox, account: account) } + let(:now) { Time.zone.parse('2026-06-29 10:00:00 UTC') } + let(:store) { Conversations::UnreadCounts::FilteredCountStore } + + before do + create(:inbox_member, user: agent, inbox: visible_inbox) + end + + after do + redis_keys.each { |key| Redis::Alfred.delete(key) } + end + + it 'builds built-in filter counts from unread open conversations visible to the user' do + mentioned = create_visible_unread_conversation + participating = create_visible_unread_conversation + create_visible_unread_conversation(unattended: true) + hidden_mention = create_unread_conversation(account: account, inbox: hidden_inbox) + resolved_mention = create_visible_unread_conversation(status: :resolved) + read_mention = create_visible_unread_conversation(agent_last_seen_at: 1.minute.from_now) + + [mentioned, hidden_mention, resolved_mention, read_mention].each do |conversation| + create(:mention, account: account, conversation: conversation, user: agent) + end + create(:conversation_participant, account: account, conversation: participating, user: agent) + + expect(counter.perform).to include( + mentions_count: 1, + participating_count: 1, + unattended_count: 1 + ) + end + + it 'returns stale built-in counts until the refresh interval elapses' do + mentioned = create_visible_unread_conversation + create(:mention, account: account, conversation: mentioned, user: agent) + + expect(counter.perform[:mentions_count]).to eq(1) + + second_mention = create_visible_unread_conversation + create(:mention, account: account, conversation: second_mention, user: agent) + store.bump_conversation_version!(account.id) + + expect( + described_class.new( + account: account, + user: agent, + now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second + ).perform[:mentions_count] + ).to eq(1) + + Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id)) + expect( + described_class.new( + account: account, + user: agent, + now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second + ).perform[:mentions_count] + ).to eq(2) + end + + it 'returns stale built-in counts when a refresh build hits a database error' do + mentioned = create_visible_unread_conversation + create(:mention, account: account, conversation: mentioned, user: agent) + + expect(counter.perform[:mentions_count]).to eq(1) + + store.bump_conversation_version!(account.id) + Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id)) + failing_counter = described_class.new( + account: account, + user: agent, + now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second + ) + allow(failing_counter).to receive(:built_in_counts_from_database).and_raise(ActiveRecord::StatementInvalid.new('statement timeout')) + + expect(failing_counter.perform[:mentions_count]).to eq(1) + end + + it 'tags built-in snapshots with versions captured before the DB read' do + race_counter = described_class.new(account: account, user: agent, now: now) + allow(race_counter).to receive(:built_in_counts_from_database) do + store.bump_conversation_version!(account.id) + { mentions_count: 1, participating_count: 0, unattended_count: 0 } + end + + race_counter.perform + + snapshot = store.built_in_filter_counts(account_id: account.id, user_id: agent.id) + expect(snapshot[:account_version]).to eq(0) + expect(store.built_in_filter_counts_state(account_id: account.id, user_id: agent.id, now: now)).to be_stale + end + + it 'tags folder indexes with versions captured before the DB read' do + race_counter = described_class.new(account: account, user: agent, now: now) + allow(race_counter).to receive(:folder_filter_ids_from_database) do + store.bump_folder_index_version!(account_id: account.id, user_id: agent.id) + [] + end + + race_counter.send(:build_folder_index!, race_counter.send(:version_cache).folder_index) + + snapshot = store.folder_index(account_id: account.id, user_id: agent.id) + expect(snapshot[:folder_index_version]).to eq(0) + expect(store.folder_index_state(account_id: account.id, user_id: agent.id, now: now)).to be_stale + end + + it 'builds saved folder counts from unread conversations matching the saved filter query' do + resolved = create_visible_unread_conversation(status: :resolved) + create_visible_unread_conversation(status: :open) + hidden_resolved = create_unread_conversation(account: account, inbox: hidden_inbox) + hidden_resolved.update!(status: :resolved) + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'status', values: ['resolved']) + ) + + expect(counter.perform[:folders]).to eq(custom_filter.id.to_s => 1) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)[:count]).to eq(1) + expect(resolved.reload.status).to eq('resolved') + end + + it 'caps inline saved filter builds per request' do + create_visible_unread_conversation(status: :open) + max_inline_filter_builds = Conversations::UnreadCounts::MAX_INLINE_FILTER_BUILDS + custom_filters = Array.new(max_inline_filter_builds + 1) do + create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'status', values: ['open']) + ) + end + query_counter = instance_double(Conversations::UnreadCounts::FilterQueryCounter, perform: 1) + allow(Conversations::UnreadCounts::FilterQueryCounter).to receive(:new).and_return(query_counter) + + result = counter.perform + + expect(result[:folders].size).to eq(max_inline_filter_builds) + expect(Conversations::UnreadCounts::FilterQueryCounter).to have_received(:new).exactly(max_inline_filter_builds).times + expect(custom_filters.count { |custom_filter| store.filter_count(account_id: account.id, filter_id: custom_filter.id).present? }).to eq( + max_inline_filter_builds + ) + end + + it 'reuses shared versions while resolving multiple saved filters' do + create_visible_unread_conversation(status: :open) + 2.times do + create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'status', values: ['open']) + ) + end + query_counter = instance_double(Conversations::UnreadCounts::FilterQueryCounter, perform: 1) + allow(Conversations::UnreadCounts::FilterQueryCounter).to receive(:new).and_return(query_counter) + expect(store).to receive(:conversation_version).with(account.id).once.and_call_original + expect(store).to receive(:built_in_filter_version).with(account_id: account.id, user_id: agent.id).once.and_call_original + + expect(counter.perform[:folders].size).to eq(2) + end + + it 'tags saved filter counts with versions captured before the DB read' do + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'status', values: ['open']) + ) + race_counter = described_class.new(account: account, user: agent, now: now) + allow(race_counter).to receive(:filter_query_count) do + store.bump_filter_version!(account_id: account.id, filter_id: custom_filter.id) + 1 + end + + race_counter.send(:build_filter_count!, custom_filter.id, race_counter.send(:version_cache).filter(custom_filter.id)) + + snapshot = store.filter_count(account_id: account.id, filter_id: custom_filter.id) + expect(snapshot[:filter_version]).to eq(0) + expect(store.filter_count_state(account_id: account.id, filter_id: custom_filter.id, owner_user_id: agent.id, now: now)).to be_stale + end + + it 'tags saved filter counts with versions captured before loading the filter row' do + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'status', values: ['open']) + ) + filters = account.custom_filters + allow(account).to receive(:custom_filters).and_return(filters) + allow(filters).to receive(:find_by) do + store.bump_filter_version!(account_id: account.id, filter_id: custom_filter.id) + custom_filter + end + + counter.send(:build_filter_count!, custom_filter.id, counter.send(:version_cache).filter(custom_filter.id)) + + snapshot = store.filter_count(account_id: account.id, filter_id: custom_filter.id) + expect(snapshot[:filter_version]).to eq(0) + expect(store.filter_count_state(account_id: account.id, filter_id: custom_filter.id, owner_user_id: agent.id, now: now)).to be_stale + end + + it 'omits invalid saved folders without writing a badge count' do + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'unknown_attribute', values: ['value']) + ) + + expect(counter.perform[:folders]).to eq({}) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil + end + + it 'records snapshot lifecycle instrumentation while calculating counts' do + allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:observe) do |_operation, _attributes, &block| + block.call + end + allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:increment) + + counter.perform + + expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:observe).with(:counter_perform, account_id: account.id) + expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:observe).with( + :snapshot_build, + account_id: account.id, + snapshot_scope: :built_in_filter + ) + expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:increment).with( + :snapshot_state, + account_id: account.id, + snapshot_scope: :built_in_filter, + snapshot_status: :missing + ) + expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:increment).with( + :refresh_claim, + account_id: account.id, + snapshot_scope: :built_in_filter, + claimed: true + ) + end + + it 'records acquired build locks when snapshot builds fail' do + error = StandardError.new('snapshot failed') + lock_manager = instance_double(Redis::LockManager) + resolver = Conversations::UnreadCounts::FilteredCountSnapshotResolver.new( + account: account, + now: now, + store: store, + lock_manager: lock_manager + ) + state = Conversations::UnreadCounts::FilteredCountStore::SnapshotResult.new(status: :missing, payload: nil) + + allow(lock_manager).to receive(:with_lock) + .with('lock-key', Conversations::UnreadCounts::FilteredCountSnapshotResolver::BUILD_LOCK_TTL) + .and_yield + .and_return(true) + allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:observe) do |_operation, _attributes, &block| + block.call + end + allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:increment) + + expect do + resolver.resolve(scope: :built_in_filter, state: state, lock_key: 'lock-key', claim_refresh: -> { true }) { raise error } + end.to raise_error(error) + expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:increment).with( + :build_lock, + account_id: account.id, + snapshot_scope: :built_in_filter, + acquired: true + ) + end + + it 'omits saved folders with malformed query payloads' do + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'status', values: 'open') + ) + + expect(counter.perform[:folders]).to eq({}) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil + end + + it 'omits saved folders with trailing query operators' do + query = filter_query(attribute_key: 'status', values: ['open']) + query[:payload].first[:query_operator] = 'AND' + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: query + ) + + expect(counter.perform[:folders]).to eq({}) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil + end + + it 'omits saved folders with invalid typed values' do + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'team_id', values: ['abc']) + ) + + expect(counter.perform[:folders]).to eq({}) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil + end + + it 'omits saved folders with invalid ID values' do + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'assignee_id', values: ['abc']) + ) + + expect(counter.perform[:folders]).to eq({}) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil + end + + it 'counts saved folders with display_id substring filters' do + conversation = create_visible_unread_conversation + create_visible_unread_conversation + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'display_id', filter_operator: 'contains', values: [conversation.display_id.to_s]) + ) + + expect(counter.perform[:folders]).to eq(custom_filter.id.to_s => 1) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)[:count]).to eq(1) + end + + it 'counts saved folders with display_id text fragment filters' do + create_visible_unread_conversation + create_visible_unread_conversation + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'display_id', filter_operator: 'does_not_contain', values: ['abc']) + ) + + expect(counter.perform[:folders]).to eq(custom_filter.id.to_s => 2) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)[:count]).to eq(2) + end + + it 'omits saved folders with invalid typed custom attribute values' do + create( + :custom_attribute_definition, + account: account, + attribute_model: :conversation_attribute, + attribute_key: 'budget', + attribute_display_type: :number + ) + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'budget', values: ['abc']) + ) + + expect(counter.perform[:folders]).to eq({}) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil + end + + it 'omits saved folders with text operators on typed custom attributes' do + create( + :custom_attribute_definition, + account: account, + attribute_model: :conversation_attribute, + attribute_key: 'budget', + attribute_display_type: :number + ) + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'budget', filter_operator: 'contains', values: ['123']) + ) + + expect(counter.perform[:folders]).to eq({}) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil + end + + it 'omits saved folders with invalid label values' do + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'labels', values: [1]) + ) + + expect(counter.perform[:folders]).to eq({}) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil + end + + it 'omits saved folders with invalid text values' do + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'mail_subject', values: [1]) + ) + + expect(counter.perform[:folders]).to eq({}) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil + end + + it 'omits saved folders with invalid date custom attribute values' do + create( + :custom_attribute_definition, + account: account, + attribute_model: :conversation_attribute, + attribute_key: 'renewal_on', + attribute_display_type: :date + ) + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'renewal_on', values: ['not-a-date']) + ) + + expect(counter.perform[:folders]).to eq({}) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil + end + + it 'omits saved folders when stored custom attribute values cannot be cast' do + create( + :custom_attribute_definition, + account: account, + attribute_model: :conversation_attribute, + attribute_key: 'budget', + attribute_display_type: :number + ) + query_counter = Conversations::UnreadCounts::FilterQueryCounter.new( + account: account, + user: agent, + query: filter_query(attribute_key: 'budget', filter_operator: 'is_present', values: []) + ) + relation = instance_double(ActiveRecord::Relation) + cast_error = ActiveRecord::StatementInvalid.new('PG::InvalidTextRepresentation: invalid input syntax for type numeric') + allow(cast_error).to receive(:cause).and_return(PG::InvalidTextRepresentation.new('invalid input syntax for type numeric')) + allow(query_counter).to receive(:query_builder).and_return(relation) + allow(relation).to receive(:count).and_raise(cast_error) + + expect(query_counter.perform).to be_nil + end + + it 'counts saved folders with days_before date filters' do + old_conversation = create_visible_unread_conversation + old_conversation.update!(created_at: 8.days.ago) + create_visible_unread_conversation + custom_filter = create( + :custom_filter, + account: account, + user: agent, + filter_type: :conversation, + query: filter_query(attribute_key: 'created_at', filter_operator: 'days_before', values: [7]) + ) + + expect(counter.perform[:folders]).to eq(custom_filter.id.to_s => 1) + expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)[:count]).to eq(1) + end + + def create_visible_unread_conversation(status: :open, agent_last_seen_at: 1.hour.ago, unattended: false) + conversation = create_unread_conversation(account: account, inbox: visible_inbox) + conversation.update!( + status: status, + agent_last_seen_at: agent_last_seen_at, + first_reply_created_at: unattended ? nil : Time.current, + waiting_since: unattended ? 5.minutes.ago : nil + ) + conversation + end + + def filter_query(attribute_key:, values:, filter_operator: 'equal_to') + { + payload: [{ + attribute_key: attribute_key, + attribute_model: 'standard', + filter_operator: filter_operator, + values: values + }] + } + end + + def redis_keys + version_keys + snapshot_keys + lock_and_throttle_keys + end + + def filter_ids + CustomFilter.where(account_id: account.id).pluck(:id) + end + + def version_keys + [ + store.conversation_version_key(account.id), + store.built_in_filter_version_key(account.id, agent.id), + store.folder_index_version_key(account.id, agent.id) + ] + filter_ids.map { |filter_id| store.filter_version_key(account.id, filter_id) } + end + + def snapshot_keys + [ + store.built_in_filter_counts_key(account.id, agent.id), + store.folder_index_key(account.id, agent.id) + ] + filter_ids.map { |filter_id| store.filter_count_key(account.id, filter_id) } + end + + def lock_and_throttle_keys + user_lock_and_throttle_keys + filter_lock_and_throttle_keys + end + + def user_lock_and_throttle_keys + [ + store.built_in_filter_build_lock_key(account.id, agent.id), + store.built_in_filter_refresh_throttle_key(account.id, agent.id), + store.folder_index_build_lock_key(account.id, agent.id), + store.folder_index_refresh_throttle_key(account.id, agent.id) + ] + end + + def filter_lock_and_throttle_keys + filter_ids.flat_map do |filter_id| + [ + store.filter_build_lock_key(account.id, filter_id), + store.filter_refresh_throttle_key(account.id, filter_id) + ] + end + end +end diff --git a/spec/services/conversations/unread_counts/listener_spec.rb b/spec/services/conversations/unread_counts/listener_spec.rb index fbb0a0835..f13f70ce0 100644 --- a/spec/services/conversations/unread_counts/listener_spec.rb +++ b/spec/services/conversations/unread_counts/listener_spec.rb @@ -5,6 +5,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do let(:account) { create(:account) } let(:conversation) { create(:conversation, account: account) } let(:notifier) { instance_double(Conversations::UnreadCounts::Notifier, perform: true) } + let(:filtered_store) { Conversations::UnreadCounts::FilteredCountStore } before do allow(Conversations::UnreadCounts::Notifier).to receive(:new).and_return(notifier) @@ -21,6 +22,19 @@ RSpec.describe Conversations::UnreadCounts::Listener do expect(notifier).to have_received(:perform) end + it 'refreshes unread count memberships before invalidating filtered counts when an incoming message is created' do + account.enable_features!(:conversation_unread_counts, :unread_count_for_filters) + message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming) + event = Events::Base.new('message.created', Time.zone.now, message: message) + invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator) + + allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator) + expect(notifier).to receive(:perform).ordered.and_return(true) + expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true) + + listener.message_created(event) + end + it 'ignores outgoing message creation' do message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :outgoing) event = Events::Base.new('message.created', Time.zone.now, message: message) @@ -41,6 +55,32 @@ RSpec.describe Conversations::UnreadCounts::Listener do expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new) end + it 'invalidates filtered counts when any message is created' do + account.enable_features!(:unread_count_for_filters) + message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :outgoing) + event = Events::Base.new('message.created', Time.zone.now, message: message) + + expect do + listener.message_created(event) + end.to change { filtered_store.conversation_version(account.id) }.by(1) + expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new) + end + + it 'notifies clients when outgoing message activity changes filtered counts' do + account.enable_features!(:conversation_unread_counts, :unread_count_for_filters) + allow(Rails.configuration.dispatcher).to receive(:dispatch) + message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :outgoing) + event = Events::Base.new('message.created', Time.zone.now, message: message) + + listener.message_created(event) + + expect(Rails.configuration.dispatcher).to have_received(:dispatch).with( + 'conversation.unread_count_changed', + kind_of(Time), + conversation: conversation + ) + end + it 'refreshes unread counts when conversation status changes' do changed_attributes = { 'status' => %w[open resolved] } event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes) @@ -51,6 +91,45 @@ RSpec.describe Conversations::UnreadCounts::Listener do expect(notifier).to have_received(:perform) end + it 'refreshes unread count memberships before invalidating filtered counts when conversation status changes' do + account.enable_features!(:conversation_unread_counts, :unread_count_for_filters) + changed_attributes = { 'status' => %w[open resolved] } + event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes) + invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator) + + allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator) + expect(notifier).to receive(:perform).ordered.and_return(true) + expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true) + + listener.conversation_status_changed(event) + end + + it 'invalidates filtered counts when conversation status changes' do + account.enable_features!(:unread_count_for_filters) + changed_attributes = { 'status' => %w[open resolved] } + event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes) + + expect do + listener.conversation_status_changed(event) + end.to change { filtered_store.conversation_version(account.id) }.by(1) + end + + it 'notifies clients when a status change only affects filtered counts' do + account.enable_features!(:conversation_unread_counts, :unread_count_for_filters) + allow(notifier).to receive(:perform).and_return(false) + allow(Rails.configuration.dispatcher).to receive(:dispatch) + changed_attributes = { 'status' => %w[pending resolved] } + event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes) + + listener.conversation_status_changed(event) + + expect(Rails.configuration.dispatcher).to have_received(:dispatch).with( + 'conversation.unread_count_changed', + kind_of(Time), + conversation: conversation + ) + end + it 'refreshes unread counts when labels change' do changed_attributes = { label_list: [%w[old], %w[new]] } event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes) @@ -61,14 +140,61 @@ RSpec.describe Conversations::UnreadCounts::Listener do expect(notifier).to have_received(:perform) end - it 'ignores conversation updates unrelated to unread count dimensions' do + it 'does not invalidate filtered counts from conversation updated events' do + account.enable_features!(:unread_count_for_filters) + event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: { priority: [nil, 'high'] }) + + expect do + listener.conversation_updated(event) + end.not_to(change { filtered_store.conversation_version(account.id) }) + expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new) + end + + it 'notifies clients when filtered conversation fields change' do + account.enable_features!(:conversation_unread_counts, :unread_count_for_filters) + allow(Rails.configuration.dispatcher).to receive(:dispatch) event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: { priority: [nil, 'high'] }) listener.conversation_updated(event) + expect(Rails.configuration.dispatcher).to have_received(:dispatch).with( + 'conversation.unread_count_changed', + kind_of(Time), + conversation: conversation + ) + end + + it 'ignores conversation updates unrelated to unread count dimensions' do + event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: { identifier: %w[old new] }) + + listener.conversation_updated(event) + expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new) end + it 'invalidates filtered counts when the conversation contact changes' do + account.enable_features!(:unread_count_for_filters) + event = Events::Base.new('conversation.contact_changed', Time.zone.now, conversation: conversation) + + expect do + listener.conversation_contact_changed(event) + end.to change { filtered_store.conversation_version(account.id) }.by(1) + end + + it 'notifies clients when the conversation contact changes' do + account.enable_features!(:conversation_unread_counts, :unread_count_for_filters) + allow(Rails.configuration.dispatcher).to receive(:dispatch) + event = Events::Base.new('conversation.contact_changed', Time.zone.now, conversation: conversation) + + listener.conversation_contact_changed(event) + + expect(Rails.configuration.dispatcher).to have_received(:dispatch).with( + 'conversation.unread_count_changed', + kind_of(Time), + conversation: conversation + ) + end + it 'refreshes unread counts when assignee changes' do changed_attributes = { assignee_id: [nil, 1] } event = Events::Base.new('assignee.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes) @@ -79,6 +205,45 @@ RSpec.describe Conversations::UnreadCounts::Listener do expect(notifier).to have_received(:perform) end + it 'notifies clients when an assignee change only affects filtered counts' do + account.enable_features!(:conversation_unread_counts, :unread_count_for_filters) + allow(notifier).to receive(:perform).and_return(false) + allow(Rails.configuration.dispatcher).to receive(:dispatch) + changed_attributes = { assignee_id: [nil, 1] } + event = Events::Base.new('assignee.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes) + + listener.assignee_changed(event) + + expect(Rails.configuration.dispatcher).to have_received(:dispatch).with( + 'conversation.unread_count_changed', + kind_of(Time), + conversation: conversation + ) + end + + it 'refreshes unread count memberships before invalidating filtered counts when assignee changes' do + account.enable_features!(:conversation_unread_counts, :unread_count_for_filters) + changed_attributes = { assignee_id: [nil, 1] } + event = Events::Base.new('assignee.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes) + invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator) + + allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator) + expect(notifier).to receive(:perform).ordered.and_return(true) + expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true) + + listener.assignee_changed(event) + end + + it 'invalidates filtered counts when a user is mentioned' do + account.enable_features!(:unread_count_for_filters) + user = create(:user, account: account) + event = Events::Base.new('conversation.mentioned', Time.zone.now, conversation: conversation, user: user) + + expect do + listener.conversation_mentioned(event) + end.to change { filtered_store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1) + end + it 'refreshes unread counts when team changes' do changed_attributes = { team_id: [nil, 1] } event = Events::Base.new('team.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes) @@ -89,6 +254,47 @@ RSpec.describe Conversations::UnreadCounts::Listener do expect(notifier).to have_received(:perform) end + it 'notifies clients when a team change only affects filtered counts' do + account.enable_features!(:conversation_unread_counts, :unread_count_for_filters) + allow(notifier).to receive(:perform).and_return(false) + allow(Rails.configuration.dispatcher).to receive(:dispatch) + changed_attributes = { team_id: [nil, 1] } + event = Events::Base.new('team.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes) + + listener.team_changed(event) + + expect(Rails.configuration.dispatcher).to have_received(:dispatch).with( + 'conversation.unread_count_changed', + kind_of(Time), + conversation: conversation + ) + end + + it 'invalidates filtered counts when a conversation is deleted' do + account.enable_features!(:unread_count_for_filters) + conversation_data = deleted_conversation_data(conversation) + + expect do + listener.conversation_deleted(Events::Base.new('conversation.deleted', Time.zone.now, conversation_data: conversation_data)) + end.to change { filtered_store.conversation_version(account.id) }.by(1) + end + + it 'notifies clients when a deleted conversation only affects filtered counts' do + account.enable_features!(:conversation_unread_counts, :unread_count_for_filters) + conversation_data = deleted_conversation_data(conversation) + allow(Rails.configuration.dispatcher).to receive(:dispatch) + + listener.conversation_deleted(Events::Base.new('conversation.deleted', Time.zone.now, conversation_data: conversation_data)) + + expect(Rails.configuration.dispatcher).to have_received(:dispatch).with( + 'conversation.unread_count_changed', + kind_of(Time), + conversation_data: conversation_data.stringify_keys + ) + ensure + store.clear_account!(account.id) + end + it 'removes unread count memberships when a conversation is deleted' do account.enable_features!(:conversation_unread_counts) label = create(:label, account: account) @@ -131,6 +337,29 @@ RSpec.describe Conversations::UnreadCounts::Listener do store.clear_account!(account.id) end + it 'removes unread count memberships before invalidating filtered counts when a conversation is deleted' do + account.enable_features!(:conversation_unread_counts, :unread_count_for_filters) + conversation_data = deleted_conversation_data(conversation) + invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator) + + store.mark_base_ready!(account.id) + store.add_base_membership( + account_id: account.id, + inbox_id: conversation.inbox_id, + label_ids: [], + conversation_id: conversation.id + ) + + allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator) + allow(Rails.configuration.dispatcher).to receive(:dispatch) + expect(store).to receive(:remove_base_membership).ordered.and_call_original + expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true) + + listener.conversation_deleted(Events::Base.new('conversation.deleted', Time.zone.now, conversation_data: conversation_data)) + ensure + store.clear_account!(account.id) + end + def deleted_conversation_data(conversation) { id: conversation.id, diff --git a/spec/services/conversations/unread_counts/notifier_spec.rb b/spec/services/conversations/unread_counts/notifier_spec.rb index 1b35d37f6..a8c46be0c 100644 --- a/spec/services/conversations/unread_counts/notifier_spec.rb +++ b/spec/services/conversations/unread_counts/notifier_spec.rb @@ -29,6 +29,18 @@ RSpec.describe Conversations::UnreadCounts::Notifier do expect(Rails.configuration.dispatcher).not_to have_received(:dispatch) end + + it 'dispatches unread count changed event when filtered counts are enabled' do + conversation.account.enable_features!(:unread_count_for_filters) + + described_class.new(conversation).perform + + expect(Rails.configuration.dispatcher).to have_received(:dispatch).with( + 'conversation.unread_count_changed', + kind_of(Time), + conversation: conversation + ) + end end context 'when conversation unread counts feature is disabled' do diff --git a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb index 75abc8518..988fb0116 100644 --- a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb +++ b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb @@ -32,6 +32,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do before do account.enable_features('crm_integration') + allow(GlobalConfig).to receive(:get).and_return({}) allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' }) end diff --git a/spec/services/crm/leadsquared/processor_service_spec.rb b/spec/services/crm/leadsquared/processor_service_spec.rb index 7b99721c5..ea1a3661f 100644 --- a/spec/services/crm/leadsquared/processor_service_spec.rb +++ b/spec/services/crm/leadsquared/processor_service_spec.rb @@ -82,6 +82,36 @@ RSpec.describe Crm::Leadsquared::ProcessorService do end end + context 'when the existing lead no longer exists' do + let(:error_response) do + instance_double(HTTParty::Response, blank?: false, parsed_response: { 'ExceptionType' => 'MXInvalidEntityReferenceException' }) + end + let(:lead_not_found_error) do + Crm::Leadsquared::Api::BaseClient::ApiError.new('Lead not found', 500, error_response) + end + + before do + contact.update!(additional_attributes: { 'external' => { 'leadsquared_id' => 'stale_lead_id' } }) + + allow(lead_client).to receive(:update_lead) + .with(any_args, 'stale_lead_id') + .and_raise(lead_not_found_error) + allow(lead_client).to receive(:update_lead) + .with(any_args, 'fresh_lead_id') + .and_return(nil) + allow(lead_finder).to receive(:find_or_create) + .with(contact) + .and_return('fresh_lead_id') + end + + it 'clears the stale id and re-resolves the lead' do + service.handle_contact(contact) + + expect(lead_finder).to have_received(:find_or_create).with(contact) + expect(contact.reload.additional_attributes['external']['leadsquared_id']).to eq('fresh_lead_id') + end + end + context 'when API call raises an error' do before do allow(lead_client).to receive(:create_or_update_lead) @@ -160,6 +190,63 @@ RSpec.describe Crm::Leadsquared::ProcessorService do expect(Rails.logger).to have_received(:error).with(/LeadSquared conversation activity failed/) end end + + context 'when post_activity fails because the lead no longer exists' do + let(:error_response) do + instance_double(HTTParty::Response, blank?: false, parsed_response: { 'ExceptionType' => 'MXInvalidEntityReferenceException' }) + end + let(:lead_not_found_error) do + Crm::Leadsquared::Api::BaseClient::ApiError.new('Lead not found', 500, error_response) + end + + before do + contact.update!(additional_attributes: { 'external' => { 'leadsquared_id' => 'stale_lead_id' } }) + + allow(lead_finder).to receive(:find_or_create) + .with(contact) + .and_return('stale_lead_id', 'fresh_lead_id') + + allow(activity_client).to receive(:post_activity) + .with('stale_lead_id', 1001, activity_note) + .and_raise(lead_not_found_error) + allow(activity_client).to receive(:post_activity) + .with('fresh_lead_id', 1001, activity_note) + .and_return('healed_activity_id') + end + + it 'clears the stale id, re-resolves the lead, and retries the activity once' do + service.handle_conversation_created(conversation) + + expect(activity_client).to have_received(:post_activity).with('fresh_lead_id', 1001, activity_note) + expect(contact.reload.additional_attributes['external']['leadsquared_id']).to eq('fresh_lead_id') + expect(conversation.reload.additional_attributes['leadsquared']['created_activity_id']).to eq('healed_activity_id') + end + end + + context 'when post_activity fails with a non-recoverable error' do + let(:error_response) do + instance_double(HTTParty::Response, blank?: false, parsed_response: { 'ExceptionType' => 'MXSomeOtherException' }) + end + let(:other_error) do + Crm::Leadsquared::Api::BaseClient::ApiError.new('boom', 500, error_response) + end + + before do + allow(lead_finder).to receive(:find_or_create) + .with(contact) + .and_return('test_lead_id') + + allow(activity_client).to receive(:post_activity).and_raise(other_error) + allow(Rails.logger).to receive(:error) + end + + it 'logs once and does not retry' do + service.handle_conversation_created(conversation) + + expect(activity_client).to have_received(:post_activity).once + expect(Rails.logger).to have_received(:error).with(/LeadSquared conversation activity failed/) + end + end end context 'when conversation activities are disabled' do diff --git a/spec/services/data_imports/intercom/activity_content_builder_spec.rb b/spec/services/data_imports/intercom/activity_content_builder_spec.rb new file mode 100644 index 000000000..f928c9154 --- /dev/null +++ b/spec/services/data_imports/intercom/activity_content_builder_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +event_content = { + 'assignment' => 'Avery assigned the conversation to Support', + 'assign_and_reopen' => 'Avery assigned the conversation to Support and reopened it', + 'open' => 'Avery opened the conversation', + 'close' => 'Avery closed the conversation', + 'snoozed' => 'Avery snoozed the conversation', + 'participant_added' => 'Avery added Support as a participant', + 'participant_removed' => 'Avery removed Support as a participant', + 'conversation_attribute_updated_by_admin' => 'Avery updated conversation attributes', + 'conversation_attribute_updated_by_user' => 'Avery updated conversation attributes', + 'conversation_attribute_updated_by_workflow' => 'Avery updated conversation attributes', + 'ticket_attribute_updated_by_admin' => 'Avery updated ticket attributes', + 'ticket_state_updated_by_admin' => 'Avery updated the ticket state', + 'custom_action_started' => 'Avery started a custom action', + 'custom_action_finished' => 'Avery finished a custom action', + 'quick_reply' => 'Avery used a quick reply' +}.freeze + +RSpec.describe DataImports::Intercom::ActivityContentBuilder do + event_content.each do |part_type, expected_content| + it "builds readable content for #{part_type}" do + part = { + 'part_type' => part_type, + 'author' => { 'type' => 'admin', 'name' => 'Avery' }, + 'assigned_to' => { 'name' => 'Support' } + } + + expect(described_class.new(part).perform).to eq(expected_content) + end + end + + it 'uses a humanized fallback for unknown future event types' do + part = { 'part_type' => 'journey_stage_changed', 'author' => { 'type' => 'bot' } } + + expect(described_class.new(part).perform).to eq('Intercom automation recorded journey stage changed') + end + + it 'appends sanitized body context' do + part = { + 'part_type' => 'close', + 'author' => { 'type' => 'admin' }, + 'body' => 'Customer confirmed resolution
' + } + + expect(described_class.new(part).perform).to eq( + 'Intercom teammate closed the conversation: Customer confirmed resolution' + ) + end +end diff --git a/spec/services/data_imports/intercom/client_spec.rb b/spec/services/data_imports/intercom/client_spec.rb new file mode 100644 index 000000000..78498121a --- /dev/null +++ b/spec/services/data_imports/intercom/client_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::Client do + let(:client) { described_class.new(access_token: 'intercom-token') } + + describe '#list_contacts' do + it 'wraps transport failures in a retryable client error', :aggregate_failures do + allow(HTTParty).to receive(:get).and_raise(SocketError, 'getaddrinfo failed') + + expect { client.list_contacts }.to raise_error(DataImports::Intercom::Client::Error) do |error| + expect(error.message).to eq('Intercom API request failed before receiving a response: getaddrinfo failed') + expect(error.body).to include(transport_error_class: 'SocketError') + end + end + end +end diff --git a/spec/services/data_imports/intercom/creation_service_spec.rb b/spec/services/data_imports/intercom/creation_service_spec.rb new file mode 100644 index 000000000..4345b3715 --- /dev/null +++ b/spec/services/data_imports/intercom/creation_service_spec.rb @@ -0,0 +1,52 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::CreationService do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:validator) { instance_double(DataImports::Intercom::CredentialsValidator, perform: { 'contacts' => 12 }) } + + before do + allow(DataImports::Intercom::CredentialsValidator).to receive(:new).and_return(validator) + end + + it 'validates and creates an import with its credentials and totals', :aggregate_failures do + data_import = described_class.new( + account: account, + initiated_by: user, + source_params: { + name: 'Migration run', + source_provider: 'intercom', + access_token: ' intercom-token ', + import_types: %w[contacts] + } + ).perform + + expect(data_import).to have_attributes( + name: 'Migration run', + source_type: 'api', + source_provider: 'intercom', + import_types: %w[contacts], + access_token: 'intercom-token', + initiated_by_id: user.id + ) + expect(data_import.stats.dig('contacts', 'total')).to eq(12) + expect(data_import.active_intercom_import_run_id).to be_present + end + + it 'returns no import without validating when another import is active' do + create(:data_import, :intercom, account: account, status: :processing) + + data_import = described_class.new( + account: account, + initiated_by: user, + source_params: { + name: 'Second run', + source_provider: 'intercom', + access_token: 'intercom-token' + } + ).perform + + expect(data_import).to be_nil + expect(validator).not_to have_received(:perform) + end +end diff --git a/spec/services/data_imports/intercom/credentials_validator_spec.rb b/spec/services/data_imports/intercom/credentials_validator_spec.rb new file mode 100644 index 000000000..3d3f9698b --- /dev/null +++ b/spec/services/data_imports/intercom/credentials_validator_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::CredentialsValidator do + let(:client) { instance_double(DataImports::Intercom::Client) } + + before do + allow(DataImports::Intercom::Client).to receive(:new).with(access_token: 'intercom-token').and_return(client) + allow(client).to receive(:list_contacts) + allow(client).to receive(:list_conversations) + end + + it 'validates and counts only contacts when conversations are not selected' do + allow(client).to receive(:list_contacts).with(per_page: 1).and_return('total_count' => 42) + + totals = described_class.new(access_token: ' intercom-token ', import_types: %w[contacts]).perform + + expect(totals).to eq('contacts' => 42) + expect(client).not_to have_received(:list_conversations) + end + + it 'validates contact access and counts only conversations when contacts are not selected' do + allow(client).to receive(:list_contacts).with(per_page: 1).and_return('total_count' => 42) + allow(client).to receive(:list_conversations).with(per_page: 1).and_return('total_count' => 17) + + totals = described_class.new(access_token: 'intercom-token', import_types: %w[conversations]).perform + + expect(totals).to eq('conversations' => 17) + expect(client).to have_received(:list_contacts).with(per_page: 1) + end + + it 'keeps an undiscovered total absent' do + allow(client).to receive(:list_contacts).with(per_page: 1).and_return('data' => []) + + totals = described_class.new(access_token: 'intercom-token', import_types: %w[contacts]).perform + + expect(totals).to be_empty + end + + it 'preserves a known zero total' do + allow(client).to receive(:list_contacts).with(per_page: 1).and_return('total_count' => 0) + + totals = described_class.new(access_token: 'intercom-token', import_types: %w[contacts]).perform + + expect(totals).to eq('contacts' => 0) + end + + it 'rejects an empty access key before calling Intercom' do + expect do + described_class.new(access_token: '', import_types: %w[contacts]).perform + end.to raise_error(ArgumentError, 'Intercom access key is required.') + + expect(DataImports::Intercom::Client).not_to have_received(:new) + end +end diff --git a/spec/services/data_imports/intercom/importer_spec.rb b/spec/services/data_imports/intercom/importer_spec.rb new file mode 100644 index 000000000..67c2ec576 --- /dev/null +++ b/spec/services/data_imports/intercom/importer_spec.rb @@ -0,0 +1,968 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::Importer do + let(:account) { create(:account) } + let(:data_import) do + create( + :data_import, :intercom, + account: account + ) + end + let(:client) { instance_double(DataImports::Intercom::Client) } + let(:contact_payload) do + { + 'id' => 'contact_1', + 'external_id' => 'external_1', + 'email' => 'CUSTOMER@Example.com', + 'phone' => '15551234567', + 'name' => 'Customer One', + 'created_at' => 1_700_000_000, + 'updated_at' => 1_700_000_100 + } + end + let(:conversation_payload) do + { + 'id' => 'conversation_1', + 'created_at' => 1_700_000_000, + 'updated_at' => 1_700_000_200, + 'state' => 'closed', + 'open' => false, + 'admin_assignee_id' => 123, + 'team_assignee_id' => 456, + 'contacts' => { 'contacts' => [{ 'id' => 'contact_1' }] }, + 'source' => { + 'id' => 'source_1', + 'type' => 'email', + 'delivered_as' => 'customer_initiated', + 'subject' => 'Need help', + 'body' => 'Hello there
', + 'author' => { 'type' => 'user', 'id' => 'contact_1', 'email' => 'CUSTOMER@example.com' } + }, + 'conversation_parts' => { + 'conversation_parts' => [ + { + 'id' => 'part_1', + 'part_type' => 'comment', + 'body' => 'Admin reply
', + 'created_at' => 1_700_000_100, + 'updated_at' => 1_700_000_100, + 'author' => { 'type' => 'admin', 'id' => 'admin_1' }, + 'attachments' => [] + }, + { + 'id' => 'part_2', + 'part_type' => 'note', + 'body' => 'Internal note', + 'created_at' => 1_700_000_150, + 'updated_at' => 1_700_000_150, + 'author' => { 'type' => 'admin', 'id' => 'admin_1' }, + 'attachments' => [] + } + ] + } + } + end + + before do + account.enable_features!('data_import') + allow(DataImports::Intercom::Client).to receive(:new).with(access_token: 'intercom-token').and_return(client) + allow(client).to receive(:list_contacts).with(starting_after: nil).and_return( + 'data' => [contact_payload], + 'total_count' => 1, + 'pages' => { 'next' => nil } + ) + allow(client).to receive(:list_conversations).with(starting_after: nil).and_return( + 'conversations' => [{ 'id' => 'conversation_1' }], + 'total_count' => 1, + 'pages' => { 'next' => nil } + ) + allow(client).to receive(:retrieve_conversation).with('conversation_1').and_return(conversation_payload) + allow(client).to receive(:retrieve_contact).with('contact_1').and_return(contact_payload) + end + + it 'imports contacts, conversations, messages, and source-bucket inboxes without normal message creation callbacks', :aggregate_failures do + described_class.new(data_import: data_import).perform + + contact = account.contacts.find_by!(email: 'customer@example.com') + expect(contact.name).to eq('Customer One') + expect(contact.phone_number).to eq('+15551234567') + expect(contact).to be_lead + expect(contact.custom_attributes).to include('intercom_contact_id' => 'contact_1') + + inbox = account.inboxes.find_by!(name: 'Intercom Import - Email') + expect(inbox.channel.additional_attributes).to include('source_bucket' => 'email', 'import_placeholder' => true) + + conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1') + expect(conversation).to have_attributes( + status: 'resolved', + inbox_id: inbox.id, + contact_id: contact.id + ) + expect(conversation.additional_attributes.dig('source', 'routing_method')).to eq('source_bucket_api_inbox') + + expect(conversation.messages.order(:created_at).pluck(:content)).to eq(["Need help\n\nHello there", 'Admin reply', 'Internal note']) + expect(conversation.messages.order(:created_at).map(&:message_type)).to eq(%w[incoming outgoing outgoing]) + expect(conversation.messages.order(:created_at).last.private).to be(true) + + expect(data_import.reload).to be_completed + expect(data_import.stats).to include( + 'contacts' => include('imported' => 1, 'skipped' => 0, 'total' => 1), + 'conversations' => include('imported' => 1, 'skipped' => 0, 'total' => 1), + 'messages' => include('imported' => 3, 'skipped' => 0, 'total' => 3), + 'errors' => { 'count' => 0 } + ) + expect(data_import.processed_records).to eq(5) + expect(data_import.items.imported.count).to eq(2) + expect(DataImportMapping.where(data_import: data_import).count).to eq(5) + end + + it 'imports historical records without dispatching record events or outbound side effects', :aggregate_failures do + dispatched_events = [] + allow(Rails.configuration.dispatcher).to receive(:dispatch) do |event_name, *_args| + dispatched_events << event_name + end + clear_enqueued_jobs + + described_class.new(data_import: data_import).perform + + record_events = [ + Events::Types::CONTACT_CREATED, + Events::Types::CONTACT_UPDATED, + Events::Types::CONVERSATION_CREATED, + Events::Types::CONVERSATION_UPDATED, + Events::Types::CONVERSATION_STATUS_CHANGED, + Events::Types::ASSIGNEE_CHANGED, + Events::Types::TEAM_CHANGED, + Events::Types::MESSAGE_CREATED, + Events::Types::FIRST_REPLY_CREATED, + Events::Types::REPLY_CREATED + ] + side_effect_jobs = [SendReplyJob, EventDispatcherJob, ActionCableBroadcastJob, WebhookJob, HookJob] + + expect(dispatched_events & record_events).to be_empty + expect(enqueued_jobs.pluck(:job) & side_effect_jobs).to be_empty + expect(Notification.where(account: account)).to be_empty + end + + context 'when Intercom contact activity timestamps are available' do + let(:contact_payload) do + super().merge('last_seen_at' => 1_700_000_050, 'last_replied_at' => 1_700_000_090) + end + + it 'prefers last_seen_at for contact activity' do + described_class.new(data_import: data_import).import_contacts_page + + contact = account.contacts.find_by!(email: 'customer@example.com') + expect(contact.last_activity_at).to eq(Time.zone.at(1_700_000_050)) + end + end + + context 'when Intercom contact last_seen_at is unavailable' do + let(:contact_payload) do + super().merge('last_seen_at' => nil, 'last_replied_at' => 1_700_000_090) + end + + it 'falls back to last_replied_at for contact activity' do + described_class.new(data_import: data_import).import_contacts_page + + contact = account.contacts.find_by!(email: 'customer@example.com') + expect(contact.last_activity_at).to eq(Time.zone.at(1_700_000_090)) + end + end + + it 'leaves contact activity blank when Intercom activity timestamps are unavailable' do + described_class.new(data_import: data_import).import_contacts_page + + contact = account.contacts.find_by!(email: 'customer@example.com') + expect(contact.last_activity_at).to be_nil + end + + it 'updates message totals by delta when a conversation page is retried' do + importer = described_class.new(data_import: data_import) + + importer.import_conversations_page + importer.import_conversations_page + + expect(data_import.reload.stats.dig('messages', 'total')).to eq(3) + item = data_import.items.find_by!(source_object_type: 'conversation', source_object_id: 'conversation_1') + expect(item.metadata['message_total_contribution']).to eq(3) + end + + it 'reconciles imported message stats from same-run mappings on retry' do + described_class.new(data_import: data_import).import_conversations_page + stats = data_import.reload.stats.deep_dup + stats['messages']['imported'] = 0 + data_import.update!(stats: stats) + + described_class.new(data_import: data_import).import_conversations_page + + expect(data_import.reload.stats.dig('messages', 'imported')).to eq(3) + end + + it 'indexes imported messages for advanced search' do + allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true) + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) + reindexed_message_ids = [] + original_reindex_for_search = Message.instance_method(:reindex_for_search) + Message.define_method(:reindex_for_search) { reindexed_message_ids << id } + Message.__send__(:private, :reindex_for_search) + + described_class.new(data_import: data_import).perform + + expect(reindexed_message_ids).to match_array(Message.where(account_id: account.id).pluck(:id)) + ensure + Message.define_method(:reindex_for_search, original_reindex_for_search) + Message.__send__(:private, :reindex_for_search) + end + + it 'keeps imported messages successful when search reindexing fails', :aggregate_failures do + allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true) + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Message).to receive(:reindex_for_search).and_raise(StandardError, 'search unavailable') + # rubocop:enable RSpec/AnyInstance + + described_class.new(data_import: data_import).perform + + message = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:source:source_1') + mapping = data_import.mappings.find_by!(source_object_type: 'message', source_object_id: 'conversation:conversation_1:source:source_1') + expect(mapping.chatwoot_record).to eq(message) + expect(data_import.reload).to be_completed + expect(data_import.import_errors.exists?).to be(false) + expect(data_import.stats.dig('messages', 'imported')).to eq(3) + end + + describe '#start!' do + it 'does not overwrite an import abandoned by another process', :aggregate_failures do + importer = described_class.new(data_import: data_import) + + DataImport.find(data_import.id).update!( + status: :abandoned, + abandoned_at: Time.current + ) + + expect(importer.start!).to be_nil + expect(data_import.reload).to be_abandoned + expect(data_import.started_at).to be_nil + end + end + + describe '#perform' do + it 'stops when the import was abandoned before processing starts' do + importer = described_class.new(data_import: data_import) + DataImport.find(data_import.id).update!( + status: :abandoned, + abandoned_at: Time.current + ) + + expect(client).not_to receive(:list_contacts) + + importer.perform + + expect(data_import.reload).to be_abandoned + end + end + + describe '#import_conversations_page' do + it 'stops an in-flight page when a newer import run takes over', :aggregate_failures do + run_id = 'intercom-run-1' + data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => run_id }) + allow(client).to receive(:list_conversations).with(starting_after: nil).and_return( + 'conversations' => [{ 'id' => 'conversation_1' }, { 'id' => 'conversation_2' }], + 'pages' => { 'next' => { 'starting_after' => 'next-conversation-cursor' } } + ) + allow(client).to receive(:retrieve_conversation).with('conversation_1') do + data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' }) + conversation_payload + end + + result = described_class.new(data_import: data_import, run_id: run_id).import_conversations_page + + expect(result).to be_done + expect(client).not_to have_received(:retrieve_conversation).with('conversation_2') + expect(account.conversations.where(identifier: 'intercom:conversation_1')).to be_empty + expect(account.contacts.where(email: 'customer@example.com')).to be_empty + expect(data_import.reload.cursor.dig('conversations', 'starting_after')).to be_nil + end + + it 'rolls back a newly inserted conversation when mapping persistence fails', :aggregate_failures do + importer = described_class.new(data_import: data_import) + allow(importer).to receive(:record_mapping).and_wrap_original do |method, object_type, source_id, record, metadata:| + raise StandardError, 'mapping failed' if object_type == 'conversation' + + method.call(object_type, source_id, record, metadata: metadata) + end + + importer.import_conversations_page + + expect(account.conversations.where(identifier: 'intercom:conversation_1')).to be_empty + item = data_import.items.find_by!(source_object_type: 'conversation', source_object_id: 'conversation_1') + expect(item).to be_failed + expect(item.last_error_message).to eq('mapping failed') + end + + it 'rolls back a newly inserted contact when mapping persistence fails', :aggregate_failures do + sparse_contact = contact_payload.slice('id', 'name', 'created_at', 'updated_at') + allow(client).to receive(:retrieve_contact).with('contact_1').and_return(sparse_contact) + importer = described_class.new(data_import: data_import) + allow(importer).to receive(:record_mapping).and_wrap_original do |method, object_type, source_id, record, metadata:| + raise StandardError, 'mapping failed' if object_type == 'contact' + + method.call(object_type, source_id, record, metadata: metadata) + end + + importer.import_conversations_page + + expect(account.contacts.where(name: 'Customer One')).to be_empty + expect(data_import.mappings.where(source_object_type: 'contact', source_object_id: 'contact_1')).to be_empty + contact_item = data_import.items.find_by!(source_object_type: 'contact', source_object_id: 'contact_1') + expect(contact_item).to be_failed + expect(contact_item.last_error_message).to eq('mapping failed') + end + + it 'rolls back a newly inserted message when mapping persistence fails', :aggregate_failures do + importer = described_class.new(data_import: data_import) + allow(importer).to receive(:record_mapping).and_wrap_original do |method, object_type, source_id, record, metadata:| + raise StandardError, 'mapping failed' if object_type == 'message' + + method.call(object_type, source_id, record, metadata: metadata) + end + + importer.import_conversations_page + + conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1') + expect(conversation.messages.where(source_id: 'intercom:conversation:conversation_1:source:source_1')).to be_empty + error = data_import.import_errors.find_by!( + source_object_type: 'message', + source_object_id: 'conversation:conversation_1:source:source_1' + ) + expect(error).to have_attributes(error_code: 'StandardError', message: 'mapping failed') + end + end + + describe '#finish!' do + it 'does not overwrite an import abandoned by another process' do + data_import.update!(status: :processing) + importer = described_class.new(data_import: data_import) + + DataImport.find(data_import.id).update!( + status: :abandoned, + abandoned_at: Time.current + ) + + importer.finish! + + expect(data_import.reload).to be_abandoned + expect(data_import.completed_at).to be_nil + end + end + + describe '#fail!' do + it 'does not overwrite an import abandoned by another process', :aggregate_failures do + data_import.update!(status: :processing) + importer = described_class.new(data_import: data_import) + + DataImport.find(data_import.id).update!( + status: :abandoned, + abandoned_at: Time.current + ) + + importer.fail!(StandardError.new('boom')) + + expect(data_import.reload).to be_abandoned + expect(data_import.last_error_at).to be_nil + expect(data_import.import_errors.exists?).to be(false) + end + end + + context 'when the Intercom records were imported by an earlier run' do + let(:next_data_import) do + create( + :data_import, :intercom, + account: account + ) + end + + it 'records the already mapped records as skipped for the current import run', :aggregate_failures do + described_class.new(data_import: data_import).perform + + described_class.new(data_import: next_data_import).perform + + expect(next_data_import.reload.stats).to include( + 'contacts' => include('imported' => 0, 'skipped' => 1, 'total' => 1), + 'conversations' => include('imported' => 0, 'skipped' => 1, 'total' => 1), + 'messages' => include('imported' => 0, 'skipped' => 3, 'total' => 3), + 'errors' => { 'count' => 0 } + ) + expect(next_data_import).to be_completed + expect(next_data_import.total_records).to eq(5) + expect(next_data_import.processed_records).to eq(0) + expect(next_data_import.items.skipped.count).to eq(2) + expect(next_data_import.import_errors.skip_logs.group(:source_object_type).count).to eq( + 'contact' => 1, + 'conversation' => 1, + 'message' => 3 + ) + expect(next_data_import.import_errors.skip_logs.pluck(:details).map { |details| details['reason'] }.uniq).to eq(['already_imported']) + end + + it 'recreates messages when existing message mappings point to deleted records', :aggregate_failures do + described_class.new(data_import: data_import).perform + conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1') + Message.where(conversation_id: conversation.id).delete_all + + described_class.new(data_import: next_data_import).perform + + expect(conversation.reload.messages.pluck(:source_id)).to match_array( + %w[ + intercom:conversation:conversation_1:source:source_1 + intercom:conversation:conversation_1:part:part_1 + intercom:conversation:conversation_1:part:part_2 + ] + ) + expect(next_data_import.reload.stats).to include( + 'contacts' => include('imported' => 0, 'skipped' => 1, 'total' => 1), + 'conversations' => include('imported' => 0, 'skipped' => 1, 'total' => 1), + 'messages' => include('imported' => 3, 'skipped' => 0, 'total' => 3), + 'errors' => { 'count' => 0 } + ) + expect(next_data_import.import_errors.skip_logs.where(source_object_type: 'message')).to be_empty + message_mappings = DataImportMapping.where(account: account, source_provider: 'intercom', source_object_type: 'message') + expect(message_mappings.filter_map(&:chatwoot_record).count).to eq(3) + end + + it 'updates conversation activity when a later import adds new messages to the mapped conversation', :aggregate_failures do + new_part = { + 'id' => 'part_3', + 'part_type' => 'comment', + 'body' => 'Follow-up reply
', + 'created_at' => 1_700_000_300, + 'updated_at' => 1_700_000_300, + 'author' => { 'type' => 'admin', 'id' => 'admin_1' }, + 'attachments' => [] + } + updated_conversation_payload = conversation_payload.deep_dup + updated_conversation_payload['updated_at'] = 1_700_000_300 + updated_conversation_payload['conversation_parts']['conversation_parts'] << new_part + allow(client).to receive(:retrieve_conversation).with('conversation_1').and_return( + conversation_payload, + updated_conversation_payload + ) + + described_class.new(data_import: data_import).perform + conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1') + + described_class.new(data_import: next_data_import).perform + + expect(conversation.reload.last_activity_at).to eq(Time.zone.at(1_700_000_300)) + expect(conversation.messages.find_by!(source_id: 'intercom:conversation:conversation_1:part:part_3').content).to eq('Follow-up reply') + end + end + + context 'when a conversation references an already mapped contact' do + it 'reuses the mapped contact without hydrating the sparse reference' do + described_class.new(data_import: data_import).import_contacts_page + + expect(client).not_to receive(:retrieve_contact) + + described_class.new(data_import: data_import).import_conversations_page + end + end + + context 'when a same-run contact mapping outlives its item progress' do + let!(:mapped_contact) { create(:contact, account: account) } + + before do + DataImportMapping.create!( + account: account, + data_import: data_import, + source_provider: 'intercom', + source_object_type: 'contact', + source_object_id: 'contact_1', + chatwoot_record_type: 'Contact', + chatwoot_record_id: mapped_contact.id, + metadata: {} + ) + data_import.items.create!( + source_provider: 'intercom', + source_object_type: 'contact', + source_object_id: 'contact_1', + status: :processing, + metadata: contact_payload + ) + end + + it 'repairs the item and imported count on retry', :aggregate_failures do + described_class.new(data_import: data_import).import_contacts_page + + item = data_import.items.find_by!(source_object_type: 'contact', source_object_id: 'contact_1') + expect(item).to be_imported + expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: mapped_contact.id) + expect(data_import.reload.stats.dig('contacts', 'imported')).to eq(1) + end + end + + context 'when an existing contact has the same email but a different external id' do + let(:contact_payload) do + super().merge('last_replied_at' => 1_700_000_090) + end + let!(:existing_contact) { create(:contact, account: account, email: 'customer@example.com', identifier: nil) } + + it 'updates the existing contact instead of creating a duplicate', :aggregate_failures do + described_class.new(data_import: data_import).import_contacts_page + + expect(existing_contact.reload.identifier).to eq('external_1') + expect(existing_contact.last_activity_at).to eq(Time.zone.at(1_700_000_090)) + expect(account.contacts.where(email: 'customer@example.com').count).to eq(1) + item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1') + expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id) + end + end + + context 'when an existing contact has the same phone but a different external id' do + let(:contact_payload) do + super().merge('email' => nil) + end + let!(:existing_contact) { create(:contact, account: account, phone_number: '+15551234567', identifier: nil) } + + it 'updates the existing contact instead of creating a duplicate', :aggregate_failures do + described_class.new(data_import: data_import).import_contacts_page + + expect(existing_contact.reload.identifier).to eq('external_1') + expect(account.contacts.where(phone_number: '+15551234567').count).to eq(1) + item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1') + expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id) + end + end + + context 'when an existing contact has the same phone but Intercom sends a new email' do + let!(:existing_contact) { create(:contact, account: account, phone_number: '+15551234567', identifier: nil) } + + it 'falls through to the phone match after the email lookup misses', :aggregate_failures do + described_class.new(data_import: data_import).import_contacts_page + + expect(existing_contact.reload.email).to eq('customer@example.com') + expect(existing_contact.identifier).to eq('external_1') + expect(account.contacts.where(phone_number: '+15551234567').count).to eq(1) + item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1') + expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id) + end + end + + context 'when an existing visitor contact matches the Intercom external id' do + let!(:existing_contact) { create(:contact, account: account, identifier: 'external_1') } + + it 'promotes the contact to a lead when adding email or phone', :aggregate_failures do + expect(existing_contact).to be_visitor + + described_class.new(data_import: data_import).import_contacts_page + + expect(existing_contact.reload).to be_lead + expect(existing_contact.email).to eq('customer@example.com') + expect(existing_contact.phone_number).to eq('+15551234567') + end + end + + context 'when an identifier match has contact details owned by another contact' do + let!(:existing_contact) { create(:contact, account: account, identifier: 'external_1') } + let!(:email_owner) { create(:contact, account: account, email: 'customer@example.com') } + let!(:phone_owner) { create(:contact, account: account, phone_number: '+15551234567') } + + it 'does not copy the conflicting email or phone number', :aggregate_failures do + described_class.new(data_import: data_import).import_contacts_page + + expect(existing_contact.reload.email).to be_nil + expect(existing_contact.phone_number).to be_nil + expect(existing_contact).to be_visitor + expect(email_owner.reload.email).to eq('customer@example.com') + expect(phone_owner.reload.phone_number).to eq('+15551234567') + expect(account.contacts.where(email: 'customer@example.com').count).to eq(1) + expect(account.contacts.where(phone_number: '+15551234567').count).to eq(1) + + item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1') + expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id) + end + end + + context 'when Intercom rate limits a conversation detail request' do + before do + allow(client).to receive(:retrieve_conversation).with('conversation_1').and_raise( + DataImports::Intercom::Client::RateLimitError.new('rate limited', status: 429) + ) + end + + it 're-raises the provider error so the page job can retry', :aggregate_failures do + expect { described_class.new(data_import: data_import).import_conversations_page } + .to raise_error(DataImports::Intercom::Client::RateLimitError) + + item = data_import.items.find_by!(source_object_type: 'conversation', source_object_id: 'conversation_1') + expect(item).to be_processing + expect(data_import.import_errors.exists?).to be(false) + end + end + + context 'when Intercom rate limits a contact hydration request' do + before do + allow(client).to receive(:retrieve_contact).with('contact_1').and_raise( + DataImports::Intercom::Client::RateLimitError.new('rate limited', status: 429) + ) + end + + it 're-raises the provider error instead of importing a sparse contact', :aggregate_failures do + expect { described_class.new(data_import: data_import).import_conversations_page } + .to raise_error(DataImports::Intercom::Client::RateLimitError) + + expect(data_import.items.exists?(source_object_type: 'contact')).to be(false) + expect(data_import.import_errors.exists?).to be(false) + end + end + + context 'when Intercom no longer has a sparse contact referenced by a conversation' do + before do + allow(client).to receive(:retrieve_contact).with('contact_1').and_raise( + DataImports::Intercom::Client::Error.new('not found', status: 404) + ) + end + + it 'falls back to the conversation contact reference', :aggregate_failures do + expect { described_class.new(data_import: data_import).import_conversations_page }.not_to raise_error + + expect(data_import.items.imported.exists?(source_object_type: 'contact', source_object_id: 'contact_1')).to be(true) + expect(data_import.import_errors.exists?).to be(false) + end + end + + context 'when the Intercom source message only has attachments' do + let(:conversation_payload) do + super().deep_merge( + 'source' => { + 'subject' => nil, + 'body' => nil, + 'attachments' => [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }] + }, + 'conversation_parts' => { + 'conversation_parts' => [] + } + ) + end + + it 'imports the source message attachment placeholder', :aggregate_failures do + described_class.new(data_import: data_import).perform + + conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1') + expect(conversation.messages.pluck(:content)).to eq(['[Intercom attachment skipped: 1]']) + expect(conversation.messages.first.additional_attributes.dig('source', 'attachments')).to eq( + [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }] + ) + expect(data_import.reload.stats.dig('messages', 'imported')).to eq(1) + end + end + + context 'when the Intercom source message has text and attachments' do + let(:conversation_payload) do + super().deep_merge( + 'source' => { + 'attachments' => [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }] + } + ) + end + + it 'adds an attachment omission marker to the imported message', :aggregate_failures do + described_class.new(data_import: data_import).perform + + message = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:source:source_1') + expect(message.content).to eq("Need help\n\nHello there\n\n[Intercom attachment skipped: 1]") + expect(message.additional_attributes.dig('source', 'attachments')).to eq( + [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }] + ) + expect(data_import.reload.stats.dig('messages', 'skipped')).to eq(0) + end + end + + context 'when Intercom omits the conversation source' do + let(:conversation_payload) do + super().merge( + 'source' => nil, + 'first_contact_reply' => { + 'type' => 'whatsapp', + 'created_at' => 1_700_000_000, + 'url' => nil + } + ) + end + + it 'routes the conversation from the first contact reply type', :aggregate_failures do + described_class.new(data_import: data_import).perform + + inbox = account.inboxes.find_by!(name: 'Intercom Import - WhatsApp') + conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1') + + expect(conversation.inbox).to eq(inbox) + expect(conversation.additional_attributes.dig('source', 'source_type')).to eq('whatsapp') + end + end + + context 'when an Intercom chat message part cannot be imported' do + let(:conversation_payload) do + super().deep_merge( + 'conversation_parts' => { + 'conversation_parts' => [ + { + 'id' => 'blank_part', + 'part_type' => 'comment', + 'body' => nil, + 'created_at' => 1_700_000_175, + 'updated_at' => 1_700_000_175, + 'author' => { 'type' => 'admin', 'id' => 'admin_1' }, + 'attachments' => [] + } + ] + } + ) + end + + it 'records a skip log with the Intercom message source id', :aggregate_failures do + described_class.new(data_import: data_import).perform + + skip_log = data_import.import_errors.skip_logs.find_by!(source_object_type: 'message') + expect(skip_log).to have_attributes( + source_object_id: 'conversation:conversation_1:part:blank_part', + error_code: 'DataImports::Intercom::SkippedMessage', + message: 'Skipped Intercom comment event blank_part: no message body or attachments to import.' + ) + expect(skip_log.details).to include( + 'kind' => 'skipped', + 'reason' => 'blank_or_unsupported_intercom_part', + 'reason_details' => 'no message body or attachments to import', + 'event_name' => 'comment', + 'event_type' => 'comment', + 'author_type' => 'admin' + ) + expect(data_import.reload.stats.dig('messages', 'skipped')).to eq(1) + end + + it 'records the skip log again for a later import run', :aggregate_failures do + described_class.new(data_import: data_import).perform + next_data_import = create( + :data_import, :intercom, + account: account + ) + + described_class.new(data_import: next_data_import).perform + + skip_log = next_data_import.import_errors.skip_logs.find_by!( + source_object_type: 'message', + source_object_id: 'conversation:conversation_1:part:blank_part', + error_code: 'DataImports::Intercom::SkippedMessage' + ) + expect(skip_log).to have_attributes( + source_object_id: 'conversation:conversation_1:part:blank_part', + error_code: 'DataImports::Intercom::SkippedMessage' + ) + expect(next_data_import.reload.stats.dig('messages', 'skipped')).to eq(2) + end + + it 'reconciles a same-run skipped mapping and missing skip log on retry', :aggregate_failures do + described_class.new(data_import: data_import).import_conversations_page + data_import.import_errors.where(source_object_type: 'message').delete_all + stats = data_import.reload.stats.deep_dup + stats['messages']['skipped'] = 0 + data_import.update!(stats: stats) + + described_class.new(data_import: data_import).import_conversations_page + + expect(data_import.reload.stats.dig('messages', 'skipped')).to eq(1) + expect(data_import.import_errors.skip_logs.exists?(source_object_id: 'conversation:conversation_1:part:blank_part')).to be(true) + end + + it 'repairs a previously skipped mapping when the part is now an activity', :aggregate_failures do + described_class.new(data_import: data_import).perform + previous_skip_log = data_import.import_errors.skip_logs.find_by!(source_object_id: 'conversation:conversation_1:part:blank_part') + conversation_payload.dig('conversation_parts', 'conversation_parts').first.merge!( + 'part_type' => 'assignment', + 'assigned_to' => { 'name' => 'Support' } + ) + next_data_import = create(:data_import, :intercom, account: account) + + described_class.new(data_import: next_data_import).perform + + activity = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:part:blank_part') + mapping = DataImportMapping.find_by!( + account: account, + source_provider: 'intercom', + source_object_type: 'message', + source_object_id: 'conversation:conversation_1:part:blank_part' + ) + expect(activity).to be_activity + expect(activity.content).to eq('Intercom teammate assigned the conversation to Support') + expect(mapping.chatwoot_record).to eq(activity) + expect(data_import.import_errors.skip_logs).to include(previous_skip_log) + expect(next_data_import.import_errors.skip_logs.where(source_object_id: mapping.source_object_id)).to be_empty + end + end + + context 'when Intercom returns bodyless lifecycle events' do + let(:conversation_payload) do + super().deep_merge( + 'conversation_parts' => { + 'total_count' => 1, + 'conversation_parts' => [ + { + 'id' => 'assignment_part', + 'part_type' => 'assignment', + 'body' => nil, + 'created_at' => 1_700_000_175, + 'author' => { 'type' => 'admin', 'name' => 'Avery' }, + 'assigned_to' => { 'type' => 'team', 'name' => 'Support' }, + 'state' => 'open', + 'tags' => { 'tags' => [{ 'name' => 'priority' }] }, + 'event_details' => { 'source' => 'workflow' }, + 'app_package_code' => 'workflow' + } + ] + } + ) + end + + it 'imports events as public activity messages with source metadata', :aggregate_failures do + described_class.new(data_import: data_import).perform + + activity = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:part:assignment_part') + expect(activity).to have_attributes( + message_type: 'activity', + content: 'Avery assigned the conversation to Support', + private: false, + sender: nil, + created_at: Time.zone.at(1_700_000_175) + ) + expect(activity.additional_attributes['source']).to include( + 'part_type' => 'assignment', + 'assigned_to' => include('name' => 'Support'), + 'state' => 'open', + 'event_details' => include('source' => 'workflow'), + 'app_package_code' => 'workflow' + ) + expect(data_import.reload.stats['messages']).to include('imported' => 2, 'skipped' => 0, 'total' => 2) + expect(data_import.import_errors.skip_logs).to be_empty + end + end + + context 'when Intercom omits older conversation parts from the retrieved conversation' do + let(:conversation_payload) do + super().deep_merge( + 'conversation_parts' => { + 'total_count' => 503 + }, + 'statistics' => { + 'count_conversation_parts' => 503 + } + ) + end + + it 'records an incomplete import error and completes with errors', :aggregate_failures do + described_class.new(data_import: data_import).perform + + error = data_import.import_errors.non_skip_logs.find_by!( + source_object_type: 'conversation', + source_object_id: 'conversation_1', + error_code: 'DataImports::Intercom::TruncatedConversationParts' + ) + expect(error.message).to eq('Intercom returned 2 of 503 conversation parts.') + expect(error.details).to include( + 'kind' => 'incomplete', + 'imported_parts_count' => 2, + 'total_parts_count' => 503 + ) + expect(data_import.reload).to be_completed_with_errors + expect(data_import.stats.dig('errors', 'count')).to eq(1) + end + end + + context 'when the conversation parts total matches the returned parts' do + let(:conversation_payload) do + super().deep_merge( + 'conversation_parts' => { + 'total_count' => 2 + }, + 'statistics' => { + 'count_conversation_parts' => 2 + } + ) + end + + it 'does not record a truncated parts error', :aggregate_failures do + described_class.new(data_import: data_import).perform + + expect(data_import.import_errors.non_skip_logs).to be_empty + expect(data_import.reload).to be_completed + expect(data_import.stats.dig('errors', 'count')).to eq(0) + end + end + + context 'when Intercom statistics count is higher than the conversation parts total' do + let(:conversation_payload) do + super().deep_merge( + 'source' => {}, + 'conversation_parts' => { + 'total_count' => 2 + }, + 'statistics' => { + 'count_conversation_parts' => 3 + } + ) + end + + it 'trusts the returned conversation parts total over the statistics counter', :aggregate_failures do + described_class.new(data_import: data_import).perform + + expect(data_import.import_errors.non_skip_logs).to be_empty + expect(data_import.reload).to be_completed + expect(data_import.stats.dig('errors', 'count')).to eq(0) + end + end + + context 'when a specific Intercom message part fails to persist' do + let(:conversation_payload) do + super().deep_merge( + 'conversation_parts' => { + 'conversation_parts' => [ + { + 'id' => 'bad_part', + 'part_type' => 'comment', + 'body' => 'Message that cannot be stored
', + 'created_at' => 1_700_000_175, + 'updated_at' => 1_700_000_175, + 'author' => { 'type' => 'admin', 'id' => 'admin_1' }, + 'attachments' => [] + } + ] + } + ) + end + + before do + allow(Message).to receive(:insert_all!).and_wrap_original do |method, records, **kwargs| + raise ActiveRecord::StatementInvalid, 'bad message' if records.first[:source_id] == 'intercom:conversation:conversation_1:part:bad_part' + + method.call(records, **kwargs) + end + end + + it 'records a skip log with the Intercom message part id', :aggregate_failures do + described_class.new(data_import: data_import).perform + + skip_log = data_import.import_errors.skip_logs.find_by!(source_object_type: 'message') + expect(skip_log).to have_attributes( + source_object_id: 'conversation:conversation_1:part:bad_part', + error_code: 'ActiveRecord::StatementInvalid', + message: 'bad message' + ) + expect(skip_log.details).to include( + 'kind' => 'failed', + 'conversation_id' => 'intercom:conversation_1' + ) + expect(data_import.reload).to be_completed_with_errors + expect(data_import.stats.dig('errors', 'count')).to eq(1) + end + end +end diff --git a/spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb b/spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb new file mode 100644 index 000000000..c1a7baab5 --- /dev/null +++ b/spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::PlaceholderInboxBuilder do + let(:account) { create(:account) } + + describe '#inbox_for' do + it 'creates a source-bucket API inbox for an Intercom conversation source' do + inbox = described_class.new(account: account).inbox_for('email') + + expect(inbox.name).to eq('Intercom Import - Email') + expect(inbox.channel).to be_a(Channel::Api) + expect(inbox.enable_auto_assignment).to be(false) + expect(inbox.allow_messages_after_resolved).to be(false) + expect(inbox.channel.additional_attributes).to include( + 'source_provider' => 'intercom', + 'source_bucket' => 'email', + 'import_placeholder' => true, + 'agent_reply_time_window' => 1 + ) + end + + it 'reuses an existing placeholder inbox for the same source bucket' do + builder = described_class.new(account: account) + + first_inbox = builder.inbox_for('phone_call') + expect(account).not_to receive(:inboxes) + second_inbox = builder.inbox_for('phone_switch') + + expect(second_inbox).to eq(first_inbox) + expect(Inbox.where(account: account, channel_type: 'Channel::Api').count).to eq(1) + end + end +end diff --git a/spec/services/data_imports/intercom/restart_service_spec.rb b/spec/services/data_imports/intercom/restart_service_spec.rb new file mode 100644 index 000000000..5dbc8f5ab --- /dev/null +++ b/spec/services/data_imports/intercom/restart_service_spec.rb @@ -0,0 +1,64 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::RestartService do + let(:account) { create(:account) } + let(:data_import) { create(:data_import, :intercom, account: account, status: :abandoned, abandoned_at: 1.hour.ago) } + + it 'prepares a failed or abandoned import for another run', :aggregate_failures do + data_import.update!( + stats: { + 'contacts' => { 'imported' => 1, 'skipped' => 9, 'total' => 10 }, + 'conversations' => { 'imported' => 2, 'skipped' => 8, 'total' => 10 }, + 'messages' => { 'imported' => 3, 'skipped' => 7, 'total' => 10 }, + 'errors' => { 'count' => 6 } + } + ) + data_import.import_errors.create!(error_code: 'StandardError', message: 'old run error') + data_import.import_errors.create!( + error_code: 'ContactFailed', + message: 'old contact error', + source_object_type: 'contact', + details: { kind: 'failed' } + ) + retained_skip_log = data_import.import_errors.create!( + error_code: DataImports::Intercom::Importer::ALREADY_IMPORTED_ERROR_CODE, + message: 'old skip log', + source_object_type: 'contact', + details: { kind: 'skipped' } + ) + previous_run_id = data_import.assign_active_intercom_import_run_id + data_import.save! + service = described_class.new(account: account, data_import: data_import) + + expect(service.perform).to eq(:enqueue) + expect(service.data_import).to be_pending + expect(service.data_import.abandoned_at).to be_nil + expect(service.data_import.started_at).to be_nil + expect(service.data_import.active_intercom_import_run_id).not_to eq(previous_run_id) + expect(service.data_import.import_errors).to contain_exactly(retained_skip_log) + expect(service.data_import.stats).to eq( + 'contacts' => { 'imported' => 1, 'skipped' => 1, 'total' => 10 }, + 'conversations' => { 'imported' => 2, 'skipped' => 0, 'total' => 10 }, + 'messages' => { 'imported' => 3, 'skipped' => 0, 'total' => 10 }, + 'errors' => { 'count' => 0 } + ) + end + + it 'returns the active import instead of restarting another import', :aggregate_failures do + active_import = create(:data_import, :intercom, account: account, status: :processing) + service = described_class.new(account: account, data_import: data_import) + + expect(service.perform).to eq(:render_show) + expect(service.data_import).to eq(active_import) + expect(data_import.reload).to be_abandoned + end + + it 'does not restart when the stored access token is missing' do + data_import.update!(access_token: nil) + + result = described_class.new(account: account, data_import: data_import).perform + + expect(result).to eq(:access_token_missing) + expect(data_import.reload).to be_abandoned + end +end diff --git a/spec/services/data_imports/intercom/source_bucket_spec.rb b/spec/services/data_imports/intercom/source_bucket_spec.rb new file mode 100644 index 000000000..b3db4294c --- /dev/null +++ b/spec/services/data_imports/intercom/source_bucket_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +RSpec.describe DataImports::Intercom::SourceBucket do + describe '.for' do + it 'maps Intercom source types to Chatwoot inbox buckets' do + expect(described_class.for('email')).to eq({ key: 'email', name: 'Email' }) + expect(described_class.for('phone_switch')).to eq({ key: 'phone', name: 'Phone' }) + expect(described_class.for('inapp')).to eq({ key: 'messenger', name: 'Messenger' }) + expect(described_class.for('messenger')).to eq({ key: 'messenger', name: 'Messenger' }) + expect(described_class.for('push')).to eq({ key: 'messenger', name: 'Messenger' }) + end + + it 'uses an unknown bucket for unsupported source types' do + expect(described_class.for('unsupported_source')).to eq({ key: 'unknown', name: 'Unknown' }) + end + end +end diff --git a/spec/services/labels/destroy_service_spec.rb b/spec/services/labels/destroy_service_spec.rb index 7d06b72d0..7f14273b3 100644 --- a/spec/services/labels/destroy_service_spec.rb +++ b/spec/services/labels/destroy_service_spec.rb @@ -6,6 +6,7 @@ describe Labels::DestroyService do let(:label) { create(:label, account: account) } let(:contact) { conversation.contact } let(:label_deleted_at) { Time.zone.parse('2026-05-07 10:00:00 UTC') } + let(:store) { Conversations::UnreadCounts::FilteredCountStore } before do conversation.label_list.add(label.title) @@ -74,6 +75,18 @@ describe Labels::DestroyService do ).perform end + it 'invalidates filtered counts when conversation label associations are removed' do + account.enable_features!(:unread_count_for_filters) + + expect do + described_class.new( + label_title: label.title, + account_id: account.id, + label_deleted_at: label_deleted_at + ).perform + end.to change { store.conversation_version(account.id) }.by(1) + end + it 'does not remove label associations created after the label was deleted' do other_conversation = create(:conversation, account: account) other_conversation.label_list.add(label.title) diff --git a/spec/services/user_session_tracking_service_spec.rb b/spec/services/user_session_tracking_service_spec.rb index 2b98b95bf..fb233c48a 100644 --- a/spec/services/user_session_tracking_service_spec.rb +++ b/spec/services/user_session_tracking_service_spec.rb @@ -3,11 +3,14 @@ require 'rails_helper' RSpec.describe UserSessionTrackingService do let(:user) { create(:user) } let(:client_id) { 'client-abc' } + let(:ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' } + let(:headers) { {} } let(:request) do instance_double( ActionDispatch::Request, - user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15', - remote_ip: '8.8.8.8' + user_agent: ua, + remote_ip: '8.8.8.8', + headers: headers ) end let(:service) { described_class.new(user: user, request: request, client_id: client_id) } @@ -49,14 +52,6 @@ RSpec.describe UserSessionTrackingService do end context 'with a Chatwoot Mobile legacy User-Agent' do - let(:request) do - instance_double( - ActionDispatch::Request, - user_agent: ua, - remote_ip: '8.8.8.8' - ) - end - context 'when the UA is okhttp (Android Chatwoot Mobile)' do let(:ua) { 'okhttp/4.9.2' } @@ -116,6 +111,113 @@ RSpec.describe UserSessionTrackingService do end end end + + context 'with X-Chatwoot-* structured headers' do + let(:ua) { 'Chatwoot/3759 CFNetwork/3886.100.1 Darwin/27.0.0' } + + context 'when platform is ios and model is an iPhone' do + let(:headers) do + { + 'X-Chatwoot-Client-Name' => 'Chatwoot Mobile', + 'X-Chatwoot-Client-Version' => '4.7.0', + 'X-Chatwoot-Platform' => 'ios', + 'X-Chatwoot-Platform-Version' => '18.2', + 'X-Chatwoot-Device-Model' => 'iPhone 15 Pro' + } + end + + it 'maps the headers into the session columns', :aggregate_failures do + service.create_or_update! + + session = user.user_sessions.last + expect(session.browser_name).to eq('Chatwoot Mobile') + expect(session.browser_version).to eq('4.7.0') + expect(session.platform_name).to eq('iPhone 15 Pro') + expect(session.platform_version).to eq('18.2') + expect(session.device_name).to eq('iPhone') + expect(session.user_agent).to eq(ua) + end + end + + context 'when platform is ios and model is an iPad' do + let(:headers) do + { + 'X-Chatwoot-Client-Name' => 'Chatwoot Mobile', + 'X-Chatwoot-Client-Version' => '4.7.0', + 'X-Chatwoot-Platform' => 'ios', + 'X-Chatwoot-Platform-Version' => '18.2', + 'X-Chatwoot-Device-Model' => 'iPad Pro 11-inch' + } + end + + it 'sets device_name to iPad so the tablet icon renders', :aggregate_failures do + service.create_or_update! + + session = user.user_sessions.last + expect(session.browser_name).to eq('Chatwoot Mobile') + expect(session.platform_name).to eq('iPad Pro 11-inch') + expect(session.device_name).to eq('iPad') + end + end + + context 'when platform is android' do + let(:ua) { 'okhttp/4.9.2' } + let(:headers) do + { + 'X-Chatwoot-Client-Name' => 'Chatwoot Mobile', + 'X-Chatwoot-Client-Version' => '4.7.0', + 'X-Chatwoot-Platform' => 'android', + 'X-Chatwoot-Platform-Version' => '14', + 'X-Chatwoot-Device-Model' => 'Pixel 7 Pro' + } + end + + it 'maps the headers into the session columns', :aggregate_failures do + service.create_or_update! + + session = user.user_sessions.last + expect(session.browser_name).to eq('Chatwoot Mobile') + expect(session.browser_version).to eq('4.7.0') + expect(session.platform_name).to eq('Pixel 7 Pro') + expect(session.platform_version).to eq('14') + expect(session.device_name).to eq('Android') + end + end + + context 'when X-Chatwoot-Client-Name is blank' do + let(:ua) { 'okhttp/4.9.2' } + let(:headers) do + { + 'X-Chatwoot-Client-Name' => '', + 'X-Chatwoot-Platform' => 'android', + 'X-Chatwoot-Device-Model' => 'Pixel 7 Pro' + } + end + + it 'falls through to the legacy UA fallback', :aggregate_failures do + service.create_or_update! + + session = user.user_sessions.last + expect(session.browser_name).to eq('Chatwoot Mobile') + expect(session.platform_name).to eq('Android') + expect(session.platform_version).to be_nil + expect(session.device_name).to eq('Android') + end + end + + context 'when no X-Chatwoot-* headers are sent (real browser)' do + let(:ua) { 'Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0' } + let(:headers) { {} } + + it 'falls through to the Browser.new path', :aggregate_failures do + service.create_or_update! + + session = user.user_sessions.last + expect(session.browser_name).to eq('Firefox') + expect(session.platform_name).to eq('Generic Linux') + end + end + end end describe '#update_activity!' do diff --git a/spec/services/whatsapp/channel_creation_service_spec.rb b/spec/services/whatsapp/channel_creation_service_spec.rb index 983af6c78..e7016f6a4 100644 --- a/spec/services/whatsapp/channel_creation_service_spec.rb +++ b/spec/services/whatsapp/channel_creation_service_spec.rb @@ -60,6 +60,17 @@ describe Whatsapp::ChannelCreationService do expect(inbox.name).to eq('Test Business WhatsApp') expect(inbox.account).to eq(account) end + + it 'does not leave an orphan channel when inbox creation fails' do + allow(Inbox).to receive(:create!).and_wrap_original do |method, *args| + method.call(*args) + raise ActiveRecord::RecordInvalid, Inbox.new + end + + expect do + expect { service.perform }.to raise_error(ActiveRecord::RecordInvalid) + end.not_to change(Channel::Whatsapp, :count) + end end context 'when channel already exists for the phone number' do diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb index 560b1993e..9c65a131b 100644 --- a/spec/services/whatsapp/embedded_signup_service_spec.rb +++ b/spec/services/whatsapp/embedded_signup_service_spec.rb @@ -158,7 +158,7 @@ describe Whatsapp::EmbeddedSignupService do account: account, inbox_id: inbox_id, phone_number_id: params[:phone_number_id], - business_id: params[:business_id] + waba_id: params[:waba_id] ).and_return(reauth_service) allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel) @@ -212,7 +212,7 @@ describe Whatsapp::EmbeddedSignupService do account: account, inbox_id: inbox.id, phone_number_id: params[:phone_number_id], - business_id: params[:business_id] + waba_id: params[:waba_id] ).and_return(reauth_service) allow(reauth_service).to receive(:perform) do diff --git a/spec/services/whatsapp/facebook_api_client_spec.rb b/spec/services/whatsapp/facebook_api_client_spec.rb index 74fb2f6e2..5dda2aaeb 100644 --- a/spec/services/whatsapp/facebook_api_client_spec.rb +++ b/spec/services/whatsapp/facebook_api_client_spec.rb @@ -154,17 +154,20 @@ describe Whatsapp::FacebookApiClient do end end - describe '#subscribe_waba_webhook' do + describe '#subscribe_phone_number_webhook' do let(:waba_id) { 'test_waba_id' } + let(:phone_number_id) { 'test_phone_id' } let(:callback_url) { 'https://example.com/webhook' } let(:verify_token) { 'test_verify_token' } context 'when successful' do before do - # Step 1: Subscribe app to WABA (no body) + # Step 1: Subscribe app to WABA with the default field list (`calls` is added only when voice is enabled). + # Pinning the body guards against regressions that drop a field and break delivery. stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") .with( - headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, + body: { subscribed_fields: %w[messages smb_message_echoes] }.to_json ) .to_return( status: 200, @@ -172,12 +175,11 @@ describe Whatsapp::FacebookApiClient do headers: { 'Content-Type' => 'application/json' } ) - # Step 2: Override callback URL (with body) - stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + # Step 2: Override callback at phone number level + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}") .with( headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, - body: { override_callback_uri: callback_url, verify_token: verify_token, - subscribed_fields: %w[messages smb_message_echoes] }.to_json + body: { webhook_configuration: { override_callback_uri: callback_url, verify_token: verify_token } }.to_json ) .to_return( status: 200, @@ -187,7 +189,7 @@ describe Whatsapp::FacebookApiClient do end it 'returns success response' do - result = api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) + result = api_client.subscribe_phone_number_webhook(waba_id, phone_number_id, callback_url, verify_token) expect(result['success']).to be(true) end end @@ -202,11 +204,13 @@ describe Whatsapp::FacebookApiClient do end it 'raises an error' do - expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/App subscription to WABA failed/) + expect do + api_client.subscribe_phone_number_webhook(waba_id, phone_number_id, callback_url, verify_token) + end.to raise_error(/App subscription to WABA failed/) end end - context 'when callback override fails' do + context 'when phone number callback override fails' do before do # Step 1 succeeds stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") @@ -220,29 +224,31 @@ describe Whatsapp::FacebookApiClient do ) # Step 2 fails - stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}") .with( headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, - body: { override_callback_uri: callback_url, verify_token: verify_token, - subscribed_fields: %w[messages smb_message_echoes] }.to_json + body: { webhook_configuration: { override_callback_uri: callback_url, verify_token: verify_token } }.to_json ) - .to_return(status: 400, body: { error: 'Webhook callback override failed' }.to_json) + .to_return(status: 400, body: { error: 'Phone number webhook callback override failed' }.to_json) end it 'raises an error' do - expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook callback override failed/) + expect do + api_client.subscribe_phone_number_webhook(waba_id, phone_number_id, callback_url, verify_token) + end.to raise_error(/Phone number webhook callback override failed/) end end end - describe '#unsubscribe_waba_webhook' do - let(:waba_id) { 'test_waba_id' } + describe '#clear_phone_number_callback_override' do + let(:phone_number_id) { 'test_phone_id' } context 'when successful' do before do - stub_request(:delete, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}") .with( - headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, + body: { webhook_configuration: { override_callback_uri: '' } }.to_json ) .to_return( status: 200, @@ -252,22 +258,23 @@ describe Whatsapp::FacebookApiClient do end it 'returns success response' do - result = api_client.unsubscribe_waba_webhook(waba_id) + result = api_client.clear_phone_number_callback_override(phone_number_id) expect(result['success']).to be(true) end end context 'when failed' do before do - stub_request(:delete, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}") .with( - headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, + body: { webhook_configuration: { override_callback_uri: '' } }.to_json ) - .to_return(status: 400, body: { error: 'Webhook unsubscription failed' }.to_json) + .to_return(status: 400, body: { error: 'Phone number webhook callback clear failed' }.to_json) end it 'raises an error' do - expect { api_client.unsubscribe_waba_webhook(waba_id) }.to raise_error(/Webhook unsubscription failed/) + expect { api_client.clear_phone_number_callback_override(phone_number_id) }.to raise_error(/Phone number webhook callback clear failed/) end end end diff --git a/spec/services/whatsapp/webhook_setup_service_spec.rb b/spec/services/whatsapp/webhook_setup_service_spec.rb index e80036f32..15d32efaf 100644 --- a/spec/services/whatsapp/webhook_setup_service_spec.rb +++ b/spec/services/whatsapp/webhook_setup_service_spec.rb @@ -42,17 +42,18 @@ describe Whatsapp::WebhookSetupService do allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number).with('123456789', 223_456) - allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) + allow(api_client).to receive(:subscribe_phone_number_webhook) + .with(waba_id, '123456789', anything, 'test_verify_token', + subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) allow(channel).to receive(:save!) end it 'registers the phone number and sets up webhook' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:register_phone_number).with('123456789', 223_456) - expect(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages - smb_message_echoes]) + expect(api_client).to receive(:subscribe_phone_number_webhook) + .with(waba_id, '123456789', 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', + subscribed_fields: %w[messages smb_message_echoes]) service.perform end end @@ -65,16 +66,17 @@ describe Whatsapp::WebhookSetupService do platform_type: 'APPLICABLE', throughput: { level: 'APPLICABLE' } }) - allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) + allow(api_client).to receive(:subscribe_phone_number_webhook) + .with(waba_id, '123456789', anything, 'test_verify_token', + subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) end it 'does NOT register phone, but sets up webhook' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).not_to receive(:register_phone_number) - expect(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages - smb_message_echoes]) + expect(api_client).to receive(:subscribe_phone_number_webhook) + .with(waba_id, '123456789', 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', + subscribed_fields: %w[messages smb_message_echoes]) service.perform end end @@ -89,17 +91,18 @@ describe Whatsapp::WebhookSetupService do }) allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number).with('123456789', 223_456) - allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) + allow(api_client).to receive(:subscribe_phone_number_webhook) + .with(waba_id, '123456789', anything, 'test_verify_token', + subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) allow(channel).to receive(:save!) end it 'registers the phone number due to pending provisioning state' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:register_phone_number).with('123456789', 223_456) - expect(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages - smb_message_echoes]) + expect(api_client).to receive(:subscribe_phone_number_webhook) + .with(waba_id, '123456789', 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', + subscribed_fields: %w[messages smb_message_echoes]) service.perform end end @@ -114,17 +117,18 @@ describe Whatsapp::WebhookSetupService do }) allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number).with('123456789', 223_456) - allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) + allow(api_client).to receive(:subscribe_phone_number_webhook) + .with(waba_id, '123456789', anything, 'test_verify_token', + subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) allow(channel).to receive(:save!) end it 'registers the phone number due to throughput not applicable' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:register_phone_number).with('123456789', 223_456) - expect(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages - smb_message_echoes]) + expect(api_client).to receive(:subscribe_phone_number_webhook) + .with(waba_id, '123456789', 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', + subscribed_fields: %w[messages smb_message_echoes]) service.perform end end @@ -139,14 +143,14 @@ describe Whatsapp::WebhookSetupService do }) allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number) - allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true }) + allow(api_client).to receive(:subscribe_phone_number_webhook).and_return({ 'success' => true }) allow(channel).to receive(:save!) end it 'tries to register phone (due to verification error) and proceeds with webhook setup' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:register_phone_number) - expect(api_client).to receive(:subscribe_waba_webhook) + expect(api_client).to receive(:subscribe_phone_number_webhook) expect { service.perform }.not_to raise_error end end @@ -156,13 +160,13 @@ describe Whatsapp::WebhookSetupService do before do allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true) allow(health_service).to receive(:fetch_health_status).and_raise('Health API down') - allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true }) + allow(api_client).to receive(:subscribe_phone_number_webhook).and_return({ 'success' => true }) end it 'does not register phone (conservative approach) and proceeds with webhook setup' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).not_to receive(:register_phone_number) - expect(api_client).to receive(:subscribe_waba_webhook) + expect(api_client).to receive(:subscribe_phone_number_webhook) expect { service.perform }.not_to raise_error end end @@ -173,14 +177,14 @@ describe Whatsapp::WebhookSetupService do allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number).and_raise('Registration failed') - allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true }) + allow(api_client).to receive(:subscribe_phone_number_webhook).and_return({ 'success' => true }) allow(channel).to receive(:save!) end it 'continues with webhook setup even if registration fails' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:register_phone_number) - expect(api_client).to receive(:subscribe_waba_webhook) + expect(api_client).to receive(:subscribe_phone_number_webhook) expect { service.perform }.not_to raise_error end end @@ -191,13 +195,13 @@ describe Whatsapp::WebhookSetupService do allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number) - allow(api_client).to receive(:subscribe_waba_webhook).and_raise('Webhook failed') + allow(api_client).to receive(:subscribe_phone_number_webhook).and_raise('Webhook failed') end it 'raises an error' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:register_phone_number) - expect(api_client).to receive(:subscribe_waba_webhook) + expect(api_client).to receive(:subscribe_phone_number_webhook) expect { service.perform }.to raise_error(/Webhook setup failed/) end end @@ -225,7 +229,7 @@ describe Whatsapp::WebhookSetupService do channel.provider_config['verification_pin'] = 123_456 allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) allow(api_client).to receive(:register_phone_number) - allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true }) + allow(api_client).to receive(:subscribe_phone_number_webhook).and_return({ 'success' => true }) allow(channel).to receive(:save!) end @@ -241,7 +245,7 @@ describe Whatsapp::WebhookSetupService do context 'when webhook setup fails and should trigger reauthorization' do before do allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true) - allow(api_client).to receive(:subscribe_waba_webhook).and_raise('Invalid access token') + allow(api_client).to receive(:subscribe_phone_number_webhook).and_raise('Invalid access token') end it 'raises error with webhook setup failure message' do @@ -282,15 +286,16 @@ describe Whatsapp::WebhookSetupService do platform_type: 'APPLICABLE', throughput: { level: 'APPLICABLE' } }) - allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'existing_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) + allow(api_client).to receive(:subscribe_phone_number_webhook) + .with(waba_id, '123456789', anything, 'existing_verify_token', + subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) end it 'successfully reauthorizes with new access token' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).not_to receive(:register_phone_number) - expect(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'existing_verify_token', + expect(api_client).to receive(:subscribe_phone_number_webhook) + .with(waba_id, '123456789', 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'existing_verify_token', subscribed_fields: %w[messages smb_message_echoes]) service_reauth.perform end @@ -298,8 +303,9 @@ describe Whatsapp::WebhookSetupService do it 'uses the existing webhook verify token during reauthorization' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do - expect(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'existing_verify_token', subscribed_fields: %w[messages smb_message_echoes]) + expect(api_client).to receive(:subscribe_phone_number_webhook) + .with(waba_id, '123456789', anything, 'existing_verify_token', + subscribed_fields: %w[messages smb_message_echoes]) service_reauth.perform end end @@ -312,8 +318,9 @@ describe Whatsapp::WebhookSetupService do platform_type: 'APPLICABLE', throughput: { level: 'APPLICABLE' } }) - allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) + allow(api_client).to receive(:subscribe_phone_number_webhook) + .with(waba_id, '123456789', anything, 'test_verify_token', + subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) end it 'completes successfully without errors' do diff --git a/spec/services/whatsapp/webhook_teardown_service_spec.rb b/spec/services/whatsapp/webhook_teardown_service_spec.rb index 2a7ba9fd0..a5bdeef0b 100644 --- a/spec/services/whatsapp/webhook_teardown_service_spec.rb +++ b/spec/services/whatsapp/webhook_teardown_service_spec.rb @@ -14,26 +14,26 @@ RSpec.describe Whatsapp::WebhookTeardownService do provider: 'whatsapp_cloud', provider_config: { 'source' => 'embedded_signup', - 'business_account_id' => 'test_waba_id', + 'phone_number_id' => 'test_phone_id', 'api_key' => 'test_api_key' } ) end - it 'calls unsubscribe_waba_webhook on Facebook API client' do + it 'calls clear_phone_number_callback_override on Facebook API client' do api_client = instance_double(Whatsapp::FacebookApiClient) allow(Whatsapp::FacebookApiClient).to receive(:new).with('test_api_key').and_return(api_client) - allow(api_client).to receive(:unsubscribe_waba_webhook).with('test_waba_id') + allow(api_client).to receive(:clear_phone_number_callback_override).with('test_phone_id') service.perform - expect(api_client).to have_received(:unsubscribe_waba_webhook).with('test_waba_id') + expect(api_client).to have_received(:clear_phone_number_callback_override).with('test_phone_id') end it 'handles errors gracefully without raising' do api_client = instance_double(Whatsapp::FacebookApiClient) allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client) - allow(api_client).to receive(:unsubscribe_waba_webhook).and_raise(StandardError, 'API Error') + allow(api_client).to receive(:clear_phone_number_callback_override).and_raise(StandardError, 'API Error') expect { service.perform }.not_to raise_error end @@ -51,18 +51,41 @@ RSpec.describe Whatsapp::WebhookTeardownService do end end - context 'when channel is whatsapp_cloud but not embedded_signup' do + context 'when channel is whatsapp_cloud with manual setup' do before do + allow(channel).to receive(:setup_webhooks).and_return(true) + channel.update!( provider: 'whatsapp_cloud', - provider_config: { 'source' => 'manual' } + provider_config: { + 'source' => 'manual', + 'phone_number_id' => 'manual_phone_id', + 'business_account_id' => 'manual_waba_id', + 'api_key' => 'manual_api_key' + } ) end - it 'does not attempt to unsubscribe webhook' do - expect(Whatsapp::FacebookApiClient).not_to receive(:new) + it 'clears the phone number callback override' do + api_client = instance_double(Whatsapp::FacebookApiClient) + allow(Whatsapp::FacebookApiClient).to receive(:new).with('manual_api_key').and_return(api_client) + allow(api_client).to receive(:clear_phone_number_callback_override).with('manual_phone_id') service.perform + + expect(api_client).to have_received(:clear_phone_number_callback_override).with('manual_phone_id') + end + + # The manual token belongs to the customer's own Meta app, so its WABA subscription is not ours to remove. + it 'does not unsubscribe the app from the WABA' do + api_client = instance_double(Whatsapp::FacebookApiClient) + allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client) + allow(api_client).to receive(:clear_phone_number_callback_override) + allow(api_client).to receive(:unsubscribe_app_from_waba) + + service.perform + + expect(api_client).not_to have_received(:unsubscribe_app_from_waba) end end diff --git a/swagger/definitions/index.yml b/swagger/definitions/index.yml index 53ff5553b..af19a9008 100644 --- a/swagger/definitions/index.yml +++ b/swagger/definitions/index.yml @@ -46,6 +46,8 @@ agent: $ref: ./resource/agent.yml inbox: $ref: ./resource/inbox.yml +branded_email_layout: + $ref: ./resource/branded_email_layout.yml inbox_contact: $ref: ./resource/inbox_contact.yml agent_bot: @@ -145,6 +147,8 @@ inbox_create_payload: $ref: ./request/inbox/create_payload.yml inbox_update_payload: $ref: ./request/inbox/update_payload.yml +account_branded_email_layout_payload: + $ref: ./request/account/branded_email_layout_payload.yml inbox_create_web_widget_channel_payload: $ref: ./request/inbox/channels/create_web_widget_channel_payload.yml inbox_create_api_channel_payload: diff --git a/swagger/definitions/request/account/branded_email_layout_payload.yml b/swagger/definitions/request/account/branded_email_layout_payload.yml new file mode 100644 index 000000000..e14aeeb48 --- /dev/null +++ b/swagger/definitions/request/account/branded_email_layout_payload.yml @@ -0,0 +1,8 @@ +type: object +properties: + branded_email_layout: + type: + - string + - 'null' + description: Account-scoped Liquid HTML layout for branded email replies. Blank or null removes the account override. + example: '{{ content_for_layout }}' diff --git a/swagger/definitions/request/inbox/update_payload.yml b/swagger/definitions/request/inbox/update_payload.yml index 2490f2101..859ad147d 100644 --- a/swagger/definitions/request/inbox/update_payload.yml +++ b/swagger/definitions/request/inbox/update_payload.yml @@ -109,6 +109,15 @@ properties: Available for: `Website` `Email` example: 'My Business' + branded_email_layout: + type: + - string + - 'null' + description: | + Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}`. + + Available for: `Email` + example: '{{ content_for_layout }}' channel: anyOf: - $ref: '#/components/schemas/inbox_update_web_widget_channel_payload' diff --git a/swagger/definitions/resource/branded_email_layout.yml b/swagger/definitions/resource/branded_email_layout.yml new file mode 100644 index 000000000..3e83b1d63 --- /dev/null +++ b/swagger/definitions/resource/branded_email_layout.yml @@ -0,0 +1,8 @@ +type: object +properties: + branded_email_layout: + type: + - string + - 'null' + description: Account-scoped Liquid HTML layout for branded email replies. + example: '{{ content_for_layout }}' diff --git a/swagger/definitions/resource/inbox.yml b/swagger/definitions/resource/inbox.yml index 5ba6b49bb..2c4b8d258 100644 --- a/swagger/definitions/resource/inbox.yml +++ b/swagger/definitions/resource/inbox.yml @@ -122,6 +122,11 @@ properties: - string - 'null' description: Business name associated with the inbox + branded_email_layout: + type: + - string + - 'null' + description: Liquid HTML layout for branded email replies. Available to administrators for Email inbox show/update responses. hmac_mandatory: type: boolean description: Whether HMAC verification is mandatory diff --git a/swagger/paths/application/branded_email_layout.yml b/swagger/paths/application/branded_email_layout.yml new file mode 100644 index 000000000..b3f671b47 --- /dev/null +++ b/swagger/paths/application/branded_email_layout.yml @@ -0,0 +1,58 @@ +get: + tags: + - Inboxes + operationId: getAccountBrandedEmailLayout + summary: Get account branded email layout + security: + - userApiKey: [] + description: Get the account-scoped Liquid HTML layout used as the fallback for branded email replies. + parameters: + - $ref: '#/components/parameters/account_id' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/branded_email_layout' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' +patch: + tags: + - Inboxes + operationId: updateAccountBrandedEmailLayout + summary: Update account branded email layout + security: + - userApiKey: [] + description: Update or clear the account-scoped Liquid HTML layout used as the fallback for branded email replies. + parameters: + - $ref: '#/components/parameters/account_id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/account_branded_email_layout_payload' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/branded_email_layout' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' + '422': + description: Validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index 55a9dd13e..e610c9662 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -411,6 +411,8 @@ $ref: ./application/conversation/reporting_events.yml # Inboxes +/api/v1/accounts/{account_id}/branded_email_layout: + $ref: ./application/branded_email_layout.yml /api/v1/accounts/{account_id}/inboxes: $ref: ./application/inboxes/index.yml /api/v1/accounts/{account_id}/inboxes/{id}: diff --git a/swagger/swagger.json b/swagger/swagger.json index b21742b4e..74f843826 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -5374,6 +5374,108 @@ } } }, + "/api/v1/accounts/{account_id}/branded_email_layout": { + "get": { + "tags": [ + "Inboxes" + ], + "operationId": "getAccountBrandedEmailLayout", + "summary": "Get account branded email layout", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get the account-scoped Liquid HTML layout used as the fallback for branded email replies.", + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/branded_email_layout" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Inboxes" + ], + "operationId": "updateAccountBrandedEmailLayout", + "summary": "Update account branded email layout", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Update or clear the account-scoped Liquid HTML layout used as the fallback for branded email replies.", + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/account_branded_email_layout_payload" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/branded_email_layout" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + }, + "422": { + "description": "Validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, "/api/v1/accounts/{account_id}/inboxes": { "get": { "tags": [ @@ -10465,6 +10567,13 @@ ], "description": "Business name associated with the inbox" }, + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Liquid HTML layout for branded email replies. Available to administrators for Email inbox show/update responses." + }, "hmac_mandatory": { "type": "boolean", "description": "Whether HMAC verification is mandatory" @@ -10510,6 +10619,19 @@ } } }, + "branded_email_layout": { + "type": "object", + "properties": { + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Account-scoped Liquid HTML layout for branded email replies.", + "example": "{{ content_for_layout }}" + } + } + }, "inbox_contact": { "type": "object", "properties": { @@ -12677,6 +12799,14 @@ "description": "Business name for outbound email replies.\n\nAvailable for: `Website` `Email`\n", "example": "My Business" }, + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}`.\n\nAvailable for: `Email`\n", + "example": "{{ content_for_layout }}" + }, "channel": { "anyOf": [ { @@ -12704,6 +12834,19 @@ } } }, + "account_branded_email_layout_payload": { + "type": "object", + "properties": { + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Account-scoped Liquid HTML layout for branded email replies. Blank or null removes the account override.", + "example": "{{ content_for_layout }}" + } + } + }, "inbox_create_web_widget_channel_payload": { "type": "object", "title": "Website channel", diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index f9ff31a7e..279bb3aaf 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -3917,6 +3917,108 @@ } } }, + "/api/v1/accounts/{account_id}/branded_email_layout": { + "get": { + "tags": [ + "Inboxes" + ], + "operationId": "getAccountBrandedEmailLayout", + "summary": "Get account branded email layout", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get the account-scoped Liquid HTML layout used as the fallback for branded email replies.", + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/branded_email_layout" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Inboxes" + ], + "operationId": "updateAccountBrandedEmailLayout", + "summary": "Update account branded email layout", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Update or clear the account-scoped Liquid HTML layout used as the fallback for branded email replies.", + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/account_branded_email_layout_payload" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/branded_email_layout" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + }, + "422": { + "description": "Validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, "/api/v1/accounts/{account_id}/inboxes": { "get": { "tags": [ @@ -8972,6 +9074,13 @@ ], "description": "Business name associated with the inbox" }, + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Liquid HTML layout for branded email replies. Available to administrators for Email inbox show/update responses." + }, "hmac_mandatory": { "type": "boolean", "description": "Whether HMAC verification is mandatory" @@ -9017,6 +9126,19 @@ } } }, + "branded_email_layout": { + "type": "object", + "properties": { + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Account-scoped Liquid HTML layout for branded email replies.", + "example": "{{ content_for_layout }}" + } + } + }, "inbox_contact": { "type": "object", "properties": { @@ -11184,6 +11306,14 @@ "description": "Business name for outbound email replies.\n\nAvailable for: `Website` `Email`\n", "example": "My Business" }, + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}`.\n\nAvailable for: `Email`\n", + "example": "{{ content_for_layout }}" + }, "channel": { "anyOf": [ { @@ -11211,6 +11341,19 @@ } } }, + "account_branded_email_layout_payload": { + "type": "object", + "properties": { + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Account-scoped Liquid HTML layout for branded email replies. Blank or null removes the account override.", + "example": "{{ content_for_layout }}" + } + } + }, "inbox_create_web_widget_channel_payload": { "type": "object", "title": "Website channel", diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json index b810a4308..b354d995c 100644 --- a/swagger/tag_groups/client_swagger.json +++ b/swagger/tag_groups/client_swagger.json @@ -1882,6 +1882,13 @@ ], "description": "Business name associated with the inbox" }, + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Liquid HTML layout for branded email replies. Available to administrators for Email inbox show/update responses." + }, "hmac_mandatory": { "type": "boolean", "description": "Whether HMAC verification is mandatory" @@ -1927,6 +1934,19 @@ } } }, + "branded_email_layout": { + "type": "object", + "properties": { + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Account-scoped Liquid HTML layout for branded email replies.", + "example": "{{ content_for_layout }}" + } + } + }, "inbox_contact": { "type": "object", "properties": { @@ -4094,6 +4114,14 @@ "description": "Business name for outbound email replies.\n\nAvailable for: `Website` `Email`\n", "example": "My Business" }, + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}`.\n\nAvailable for: `Email`\n", + "example": "{{ content_for_layout }}" + }, "channel": { "anyOf": [ { @@ -4121,6 +4149,19 @@ } } }, + "account_branded_email_layout_payload": { + "type": "object", + "properties": { + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Account-scoped Liquid HTML layout for branded email replies. Blank or null removes the account override.", + "example": "{{ content_for_layout }}" + } + } + }, "inbox_create_web_widget_channel_payload": { "type": "object", "title": "Website channel", diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json index f6e10e57c..1d7e3bcfd 100644 --- a/swagger/tag_groups/other_swagger.json +++ b/swagger/tag_groups/other_swagger.json @@ -1297,6 +1297,13 @@ ], "description": "Business name associated with the inbox" }, + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Liquid HTML layout for branded email replies. Available to administrators for Email inbox show/update responses." + }, "hmac_mandatory": { "type": "boolean", "description": "Whether HMAC verification is mandatory" @@ -1342,6 +1349,19 @@ } } }, + "branded_email_layout": { + "type": "object", + "properties": { + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Account-scoped Liquid HTML layout for branded email replies.", + "example": "{{ content_for_layout }}" + } + } + }, "inbox_contact": { "type": "object", "properties": { @@ -3509,6 +3529,14 @@ "description": "Business name for outbound email replies.\n\nAvailable for: `Website` `Email`\n", "example": "My Business" }, + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}`.\n\nAvailable for: `Email`\n", + "example": "{{ content_for_layout }}" + }, "channel": { "anyOf": [ { @@ -3536,6 +3564,19 @@ } } }, + "account_branded_email_layout_payload": { + "type": "object", + "properties": { + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Account-scoped Liquid HTML layout for branded email replies. Blank or null removes the account override.", + "example": "{{ content_for_layout }}" + } + } + }, "inbox_create_web_widget_channel_payload": { "type": "object", "title": "Website channel", diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json index 7e00568f2..c114eff05 100644 --- a/swagger/tag_groups/platform_swagger.json +++ b/swagger/tag_groups/platform_swagger.json @@ -2058,6 +2058,13 @@ ], "description": "Business name associated with the inbox" }, + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Liquid HTML layout for branded email replies. Available to administrators for Email inbox show/update responses." + }, "hmac_mandatory": { "type": "boolean", "description": "Whether HMAC verification is mandatory" @@ -2103,6 +2110,19 @@ } } }, + "branded_email_layout": { + "type": "object", + "properties": { + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Account-scoped Liquid HTML layout for branded email replies.", + "example": "{{ content_for_layout }}" + } + } + }, "inbox_contact": { "type": "object", "properties": { @@ -4270,6 +4290,14 @@ "description": "Business name for outbound email replies.\n\nAvailable for: `Website` `Email`\n", "example": "My Business" }, + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}`.\n\nAvailable for: `Email`\n", + "example": "{{ content_for_layout }}" + }, "channel": { "anyOf": [ { @@ -4297,6 +4325,19 @@ } } }, + "account_branded_email_layout_payload": { + "type": "object", + "properties": { + "branded_email_layout": { + "type": [ + "string", + "null" + ], + "description": "Account-scoped Liquid HTML layout for branded email replies. Blank or null removes the account override.", + "example": "{{ content_for_layout }}" + } + } + }, "inbox_create_web_widget_channel_payload": { "type": "object", "title": "Website channel",