Compare commits

..
Author SHA1 Message Date
aakashb95 11cbf327f0 fix(contacts): avoid unnecessary contact_inboxes preload 2026-03-11 01:21:41 +05:30
PranavandGitHub 79218be5c4 fix: Force account_id to use index on messages query on conversation push_event_data (#13757)
Add a where clause. where(account_id: account_id).
2026-03-11 01:03:18 +05:30
Shivam MishraandGitHub 8d9dd99012 fix: scenario label (#13746) 2026-03-10 18:32:44 +05:30
9f376c43b5 fix(signup): normalize account signup config checks (#13745)
This makes account signup enforcement consistent when signup is disabled
at the installation level. Email signup and Google signup now stay
blocked regardless of whether the config value is stored as a string or
a boolean.

This effectively covers the config-loader path, where `YAML.safe_load`
reads `value: false` from `installation_config.yml` as a native boolean
and persists it that way.

- Normalized the account signup check so disabled signup is handled
consistently across config value types.
- Reused the same check across API signup and Google signup entry
points.
- Added regression coverage for the disabled-signup cases in the
existing controller specs.

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-03-10 16:35:09 +05:30
824164852c refactor: extract custom attribute methods from FilterService (#13743)
- Extracted 6 custom attribute methods (`custom_attribute_query`,
`attribute_model`, `attribute_data_type`, `build_custom_attr_query`,
`custom_attribute`, `not_in_custom_attr_query`) into a new
`Filters::CustomAttributeFilterHelper` module.
- Added an inline `rubocop:disable` for the intentional
`Lint/ShadowedException` in `coerce_lt_gt_value` — `Date::Error` is a
subclass of `ArgumentError`, but both are listed explicitly for clarity.

## Why `app/services/filters/`

The existing `Filters::FilterHelper` lives in `app/helpers/filters/`,
but that location triggers `Rails/HelperInstanceVariable` for any module
that uses instance variables. The extracted methods share state with
`FilterService` via instance variables (`@attribute_key`, `@account`,
`@custom_attribute`, etc.), so placing them in `app/helpers/` would
require a cop disable.

`app/services/filters/` is a better fit because:
- The module is a service mixin, not a view helper — it's only included
by `FilterService` and its subclasses (`Conversations::FilterService`,
`Contacts::FilterService`, `AutomationRules::ConditionsFilterService`).
- It sits alongside the services that use it.
- No cop disables needed.

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-03-10 14:15:52 +05:30
8ea93ec73d chore(docs): Update documentation for messages API (#13744)
Update the documentation for messages API

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-03-10 14:15:10 +05:30
Vishnu NarayananandGitHub 28f58b3694 fix: make conversation transcript rate limit configurable (#13740)
## Summary
- The conversation transcript endpoint rate limit is hardcoded at 30
requests/hour per account with no way to override it
- Self-hosted users with active accounts hit this limit and get 429
errors across all channels
- Add `RATE_LIMIT_CONVERSATION_TRANSCRIPT` env var (default: `1000`) to
make it configurable, consistent with other throttles like
`RATE_LIMIT_CONTACT_SEARCH` and `RATE_LIMIT_REPORTS_API_ACCOUNT_LEVEL`
2026-03-10 14:11:36 +05:30
48 changed files with 553 additions and 2452 deletions
-1
View File
@@ -1,7 +1,6 @@
class V2::ReportBuilder
include DateRangeHelper
include ReportHelper
attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze
@@ -1,6 +1,5 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
include V2::Reports::Concerns::RollupConditions
def build
load_data
@@ -9,15 +8,9 @@ class V2::Reports::BaseSummaryBuilder
private
# Summary builders fetch all metrics in a single query, so the per-metric
# check from RollupConditions does not apply.
def metric_covered?
true
end
def load_data
@conversations_count = fetch_conversations_count
use_rollup? ? load_rollup_data : load_reporting_events_data
load_reporting_events_data
end
def load_reporting_events_data
@@ -41,46 +34,6 @@ class V2::Reports::BaseSummaryBuilder
@avg_reply_time = results.transform_values(&:avg_reply_time)
end
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def load_rollup_data
dimension_type = dimension_type_to_rollup
value_col = rollup_value_column
# Fetch rollup data grouped by dimension_id
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
dimension_type: dimension_type,
date: rollup_date_range
).group(:dimension_id).select(
'dimension_id',
'SUM(CASE WHEN metric = \'resolutions_count\' THEN count ELSE 0 END) as resolved_count',
"SUM(CASE WHEN metric = 'resolution_time' THEN count ELSE 0 END) as resolution_count",
"SUM(CASE WHEN metric = 'resolution_time' THEN #{value_col} ELSE 0 END) as resolution_sum_value",
"SUM(CASE WHEN metric = 'first_response' THEN count ELSE 0 END) as first_response_count",
"SUM(CASE WHEN metric = 'first_response' THEN #{value_col} ELSE 0 END) as first_response_sum_value",
"SUM(CASE WHEN metric = 'reply_time' THEN count ELSE 0 END) as reply_count",
"SUM(CASE WHEN metric = 'reply_time' THEN #{value_col} ELSE 0 END) as reply_sum_value"
)
# Convert to the expected format
results = {}
rollup_rows.each do |row|
results[row.dimension_id] = row
end
@resolved_count = results.transform_values { |r| r.resolved_count.to_i }
@avg_resolution_time = results.transform_values do |r|
r.resolution_count.to_i.zero? ? nil : (r.resolution_sum_value.to_f / r.resolution_count.to_i)
end
@avg_first_response_time = results.transform_values do |r|
r.first_response_count.to_i.zero? ? nil : (r.first_response_sum_value.to_f / r.first_response_count.to_i)
end
@avg_reply_time = results.transform_values do |r|
r.reply_count.to_i.zero? ? nil : (r.reply_sum_value.to_f / r.reply_count.to_i)
end
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
@@ -1,75 +0,0 @@
module V2::Reports::Concerns::RollupConditions
COVERED_METRICS = {
avg_first_response_time: :first_response,
avg_resolution_time: :resolution_time,
reply_time: :reply_time,
resolutions_count: :resolutions_count,
bot_resolutions_count: :bot_resolutions_count,
bot_handoffs_count: :bot_handoffs_count
}.freeze
SUPPORTED_DIMENSIONS = %w[account agent inbox].freeze
def use_rollup?
# Condition 0: reporting_timezone must be configured on account
return false if account.reporting_timezone.blank?
# Condition 1: Feature flag must be enabled
return false unless account.feature_enabled?('reporting_events_rollup')
# Condition 2: Metric must be covered by rollup
return false unless metric_covered?
# Condition 3: Not hourly granularity
return false if params[:group_by] == 'hour'
# Condition 4: Dimension must be supported
return false unless dimension_supported?
# Condition 5: Timezone must match
timezone_matches_account?
end
private
def metric_covered?
return false if params[:metric].blank?
COVERED_METRICS.key?(params[:metric].to_sym)
end
def dimension_supported?
return true if params[:type].blank? # account dimension (no specific ID)
SUPPORTED_DIMENSIONS.include?(params[:type].to_s)
end
def timezone_matches_account?
return false if params[:timezone_offset].blank?
offset_in_seconds = params[:timezone_offset].to_f * 3600
account_zone = ActiveSupport::TimeZone[account.reporting_timezone]
account_zone&.now&.utc_offset == offset_in_seconds
end
def metric_to_rollup_metric(metric)
COVERED_METRICS[metric.to_sym]
end
def rollup_value_column
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :sum_value_business_hours : :sum_value
end
def rollup_date_range
tz = ActiveSupport::TimeZone[account.reporting_timezone]
start_date = range.first.in_time_zone(tz).to_date
# range is exclusive (since...until), so subtract a second to avoid
# including the until boundary day when it falls exactly at midnight.
end_date = (range.last - 1.second).in_time_zone(tz).to_date
start_date..end_date
end
def dimension_type_to_rollup
{ 'account' => :account, 'agent' => :agent, 'inbox' => :inbox }[params[:type].to_s]
end
end
@@ -11,6 +11,11 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
load_reporting_events_data
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
@@ -1,15 +1,5 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
use_rollup? ? rollup_timeseries : raw_timeseries
end
def aggregate_value
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
end
private
def raw_timeseries
grouped_average_time = reporting_events.average(average_value_key)
grouped_event_count = reporting_events.count
grouped_average_time.each_with_object([]) do |element, arr|
@@ -22,98 +12,11 @@ class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::B
end
end
def rollup_timeseries
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
metric = metric_to_rollup_metric(params[:metric])
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
)
group_and_aggregate_rollup(rollup_rows)
end
def raw_aggregate_value
def aggregate_value
object_scope.average(average_value_key)
end
def rollup_aggregate_value
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
metric = metric_to_rollup_metric(params[:metric])
value_col = rollup_value_column
result = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
).pick(Arel.sql("SUM(count), SUM(#{value_col})"))
return nil if result.blank? || result[0].to_i.zero?
result[1].to_f / result[0].to_i
end
def dimension_id_for_rollup
params[:type].to_s == 'account' ? account.id : scope.id
end
def group_and_aggregate_rollup(rollup_rows)
grouped_data = aggregate_rollup_rows(rollup_rows, all_periods_in_range.index_with { { count: 0, sum_value: 0.0 } })
results = grouped_data.map do |date_key, data|
value = data[:count].zero? ? 0 : data[:sum_value] / data[:count]
{ value: value, timestamp: date_key.in_time_zone(timezone).to_i, count: data[:count] }
end
results.sort_by { |h| h[:timestamp] }
end
def aggregate_rollup_rows(rollup_rows, grouped_data = {})
value_col = rollup_value_column
rollup_rows.each_with_object(grouped_data) do |row, all_grouped_data|
date_key = date_key_for_group(row.date)
all_grouped_data[date_key] ||= { count: 0, sum_value: 0.0 }
all_grouped_data[date_key][:count] += row.count
all_grouped_data[date_key][:sum_value] += row.public_send(value_col)
end
end
def date_key_for_group(date)
case group_by
when 'week' then date.beginning_of_week(:sunday)
when 'month' then date.beginning_of_month
when 'year' then date.beginning_of_year
else date
end
end
def all_periods_in_range
date_range = rollup_date_range
dates = []
current = date_key_for_group(date_range.first)
while current <= date_range.last
dates << current
current = advance_period(current)
end
dates
end
def advance_period(date)
case group_by
when 'week' then date + 1.week
when 'month' then date + 1.month
when 'year' then date + 1.year
else date + 1.day
end
end
private
def event_name
metric_to_event_name = {
@@ -1,8 +1,6 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
include V2::Reports::Concerns::RollupConditions
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
@@ -1,15 +1,5 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
use_rollup? ? rollup_timeseries : raw_timeseries
end
def aggregate_value
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
end
private
def raw_timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
@@ -21,92 +11,11 @@ class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::Bas
end
end
def rollup_timeseries
metric = metric_to_rollup_metric(params[:metric])
return [] if metric.blank?
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
)
group_and_aggregate_rollup_counts(rollup_rows)
end
def raw_aggregate_value
def aggregate_value
object_scope.count
end
def rollup_aggregate_value
metric = metric_to_rollup_metric(params[:metric])
return 0 if metric.blank?
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
).sum(:count).to_i
end
def dimension_id_for_rollup
params[:type].to_s == 'account' ? account.id : scope.id
end
def group_and_aggregate_rollup_counts(rollup_rows)
# Pre-fill all periods with zero to match raw path's default_value: 0 behavior
grouped_data = all_periods_in_range.index_with { 0 }
rollup_rows.each do |row|
date_key = date_key_for_period(row.date)
grouped_data[date_key] ||= 0
grouped_data[date_key] += row.count
end
results = grouped_data.map do |date_key, count|
{ value: count, timestamp: date_key.in_time_zone(timezone).to_i }
end
results.sort_by { |h| h[:timestamp] }
end
def date_key_for_period(date)
case group_by
when 'week' then date.beginning_of_week(:sunday)
when 'month' then date.beginning_of_month
when 'year' then date.beginning_of_year
else date
end
end
def all_periods_in_range
date_range = rollup_date_range
dates = []
current = date_key_for_period(date_range.first)
while current <= date_range.last
dates << current
current = advance_period(current)
end
dates
end
def advance_period(date)
case group_by
when 'week' then date + 1.week
when 'month' then date + 1.month
when 'year' then date + 1.year
else date + 1.day
end
end
private
def metric
@metric ||= params[:metric]
@@ -201,7 +201,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def fetch_contact
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
contact_scope = Current.account.contacts
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
@contact = contact_scope.find(params[:id])
end
def process_avatar_from_url
@@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent)
render_report_with(V2::Reports::AgentSummaryBuilder)
end
def team
render_report_with(V2::Reports::TeamSummaryBuilder, type: :team)
render_report_with(V2::Reports::TeamSummaryBuilder)
end
def inbox
render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox)
render_report_with(V2::Reports::InboxSummaryBuilder)
end
def label
@@ -36,17 +36,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
until: permitted_params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
}
@builder_params[:timezone_offset] = permitted_params[:timezone_offset] if permitted_params[:timezone_offset].present?
end
def render_report_with(builder_class, type: nil)
builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params
builder = builder_class.new(account: Current.account, params: builder_params)
def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params)
render json: builder.build
end
def permitted_params
params.permit(:since, :until, :business_hours, :timezone_offset)
params.permit(:since, :until, :business_hours)
end
def date_range_too_long?
@@ -1,8 +1,6 @@
/* global axios */
import ApiClient from './ApiClient';
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
class SummaryReportsAPI extends ApiClient {
constructor() {
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
@@ -14,7 +12,6 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -25,7 +22,6 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -36,7 +32,6 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -47,7 +42,6 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -191,7 +191,7 @@ onMounted(() => {
<template>
<PageLayout
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
:header-title="$t('CAPTAIN.ASSISTANTS.SCENARIOS.TITLE')"
:is-fetching="isFetching"
:show-know-more="false"
:show-pagination-footer="false"
@@ -20,7 +20,6 @@ class ReportingEventListener < BaseListener
create_bot_resolved_event(conversation, reporting_event)
reporting_event.save!
ReportingEvents::RollupService.perform(reporting_event)
end
def first_reply_created(event)
@@ -42,7 +41,6 @@ class ReportingEventListener < BaseListener
)
reporting_event.save!
ReportingEvents::RollupService.perform(reporting_event)
end
def reply_created(event)
@@ -67,7 +65,6 @@ class ReportingEventListener < BaseListener
event_end_time: message.created_at
)
reporting_event.save!
ReportingEvents::RollupService.perform(reporting_event)
end
def conversation_bot_handoff(event)
@@ -91,7 +88,6 @@ class ReportingEventListener < BaseListener
event_end_time: conversation.updated_at
)
reporting_event.save!
ReportingEvents::RollupService.perform(reporting_event)
end
def conversation_opened(event)
@@ -143,6 +139,5 @@ class ReportingEventListener < BaseListener
bot_resolved_event = reporting_event.dup
bot_resolved_event.name = 'conversation_bot_resolved'
bot_resolved_event.save!
ReportingEvents::RollupService.new(bot_resolved_event).perform
end
end
-8
View File
@@ -85,13 +85,11 @@ class Account < ApplicationRecord
validates_with JsonSchemaValidator,
schema: SETTINGS_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.settings }
validate :validate_reporting_timezone
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :reporting_timezone
store_accessor :settings, :keep_pending_on_bot_failure
store_accessor :settings, :captain_disable_auto_resolve
@@ -216,12 +214,6 @@ class Account < ApplicationRecord
# method overridden in enterprise module
end
def validate_reporting_timezone
return if reporting_timezone.blank? || ActiveSupport::TimeZone[reporting_timezone].present?
errors.add(:reporting_timezone, I18n.t('errors.account.reporting_timezone.invalid'))
end
def remove_account_sequences
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS camp_dpid_seq_#{id}")
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS conv_dpid_seq_#{id}")
-48
View File
@@ -1,48 +0,0 @@
# == Schema Information
#
# Table name: reporting_events_rollups
#
# id :bigint not null, primary key
# count :bigint default(0), not null
# date :date not null
# dimension_id :bigint not null
# dimension_type :string not null
# metric :string not null
# sum_value :float default(0.0), not null
# sum_value_business_hours :float default(0.0), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_rollup_summary (account_id,dimension_type,date)
# index_rollup_timeseries (account_id,metric,date)
# index_rollup_unique_key (account_id,date,dimension_type,dimension_id,metric) UNIQUE
#
class ReportingEventsRollup < ApplicationRecord
belongs_to :account
# Store string values directly in the database for better readability and debugging
enum :dimension_type, %w[account agent inbox team].index_by(&:itself)
enum :metric, %w[
resolutions_count
first_response
resolution_time
reply_time
bot_resolutions_count
bot_handoffs_count
].index_by(&:itself)
validates :account_id, presence: true
validates :date, presence: true
validates :dimension_type, presence: true
validates :dimension_id, presence: true
validates :metric, presence: true
validates :count, numericality: { greater_than_or_equal_to: 0 }
scope :for_date_range, ->(start_date, end_date) { where(date: start_date..end_date) }
scope :for_dimension, ->(type, id) { where(dimension_type: type, dimension_id: id) }
scope :for_metric, ->(metric) { where(metric: metric) }
end
@@ -24,7 +24,7 @@ class Conversations::EventDataPresenter < SimpleDelegator
private
def push_messages
[messages.chat.last&.push_event_data].compact
[messages.where(account_id: account_id).chat.last&.push_event_data].compact
end
def push_meta
+2 -53
View File
@@ -2,6 +2,7 @@ require 'json'
class FilterService
include Filters::FilterHelper
include Filters::CustomAttributeFilterHelper
include CustomExceptions::CustomFilter
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
@@ -137,26 +138,8 @@ class FilterService
end
end
def custom_attribute_query(query_hash, custom_attribute_type, current_index)
@attribute_key = query_hash[:attribute_key]
@custom_attribute_type = custom_attribute_type
attribute_data_type
return '' if @custom_attribute.blank?
build_custom_attr_query(query_hash, current_index)
end
private
def attribute_model
@attribute_model = @custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
end
def attribute_data_type
attribute_type = custom_attribute(@attribute_key, @account, attribute_model).try(:attribute_display_type)
@attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
end
def standard_attribute_data_type(attribute_key)
@filters.each_value do |section|
return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key)
@@ -173,44 +156,10 @@ class FilterService
else
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
end
rescue Date::Error, ArgumentError, FloatDomainError, TypeError
rescue ArgumentError, FloatDomainError, TypeError
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
end
def build_custom_attr_query(query_hash, current_index)
filter_operator_value = filter_operation(query_hash, current_index)
query_operator = query_hash[:query_operator]
table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
query = if attribute_data_type == 'text'
ActiveRecord::Base.sanitize_sql_array(
["LOWER(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
)
else
ActiveRecord::Base.sanitize_sql_array(
["(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
)
end
query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
end
def custom_attribute(attribute_key, account, custom_attribute_type)
current_account = account || Current.account
attribute_model = custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
@custom_attribute = current_account.custom_attribute_definitions.where(
attribute_model: attribute_model
).find_by(attribute_key: attribute_key)
end
def not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
return '' unless query_hash[:filter_operator] == 'not_equal_to'
ActiveRecord::Base.sanitize_sql_array(
[" OR (#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} IS NULL ", @attribute_key]
)
end
def equals_to_filter_string(filter_operator, current_index)
return "IN (:value_#{current_index})" if filter_operator == 'equal_to'
@@ -0,0 +1,55 @@
module Filters::CustomAttributeFilterHelper
def custom_attribute_query(query_hash, custom_attribute_type, current_index)
@attribute_key = query_hash[:attribute_key]
@custom_attribute_type = custom_attribute_type
attribute_data_type
return '' if @custom_attribute.blank?
build_custom_attr_query(query_hash, current_index)
end
private
def attribute_model
@attribute_model = @custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
end
def attribute_data_type
attribute_type = custom_attribute(@attribute_key, @account, attribute_model).try(:attribute_display_type)
@attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
end
def build_custom_attr_query(query_hash, current_index)
filter_operator_value = filter_operation(query_hash, current_index)
query_operator = query_hash[:query_operator]
table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
query = if attribute_data_type == 'text'
ActiveRecord::Base.sanitize_sql_array(
["LOWER(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
)
else
ActiveRecord::Base.sanitize_sql_array(
["(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
)
end
query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
end
def custom_attribute(attribute_key, account, custom_attribute_type)
current_account = account || Current.account
attribute_model = custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
@custom_attribute = current_account.custom_attribute_definitions.where(
attribute_model: attribute_model
).find_by(attribute_key: attribute_key)
end
def not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
return '' unless query_hash[:filter_operator] == 'not_equal_to'
ActiveRecord::Base.sanitize_sql_array(
[" OR (#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} IS NULL ", @attribute_key]
)
end
end
@@ -1,130 +0,0 @@
# frozen_string_literal: true
class ReportingEvents::BackfillService
def self.backfill_date(account, date)
new(account, date).perform
end
def initialize(account, date)
@account = account
@date = date
end
def perform
# 1. Delete existing rollups for this date (idempotency)
delete_existing_rollups
# 2. Convert date in account timezone to UTC boundaries
start_utc, end_utc = date_boundaries_in_utc
# 3. Process each event type with SQL aggregation
rollup_rows = []
rollup_rows.concat(aggregate_event_type('conversation_resolved', start_utc, end_utc))
rollup_rows.concat(aggregate_event_type('first_response', start_utc, end_utc))
rollup_rows.concat(aggregate_event_type('reply_time', start_utc, end_utc))
rollup_rows.concat(aggregate_event_type('conversation_bot_resolved', start_utc, end_utc))
rollup_rows.concat(aggregate_event_type('conversation_bot_handoff', start_utc, end_utc))
# 4. Bulk insert all rollups at once
bulk_insert_rollups(rollup_rows) if rollup_rows.any?
end
private
def delete_existing_rollups
ReportingEventsRollup.where(account_id: @account.id, date: @date).delete_all
end
def date_boundaries_in_utc
tz = ActiveSupport::TimeZone[@account.reporting_timezone]
start_in_tz = tz.parse(@date.to_s)
end_in_tz = start_in_tz + 1.day
[start_in_tz.utc, end_in_tz.utc]
end
def aggregate_event_type(event_name, start_utc, end_utc)
events = @account.reporting_events
.where(name: event_name, created_at: start_utc...end_utc)
.includes(:conversation)
return [] if events.empty?
# Build in-memory aggregates by dimension
aggregates = build_aggregates(events, event_name)
# Convert to rollup row hashes
aggregates.map do |(dimension_type, dimension_id, metric), data|
{
account_id: @account.id,
date: @date,
dimension_type: dimension_type,
dimension_id: dimension_id,
metric: metric,
count: data[:count],
sum_value: data[:sum_value],
sum_value_business_hours: data[:sum_value_business_hours],
created_at: Time.current,
updated_at: Time.current
}
end
end
def build_aggregates(events, event_name)
aggregates = Hash.new { |h, k| h[k] = { count: 0, sum_value: 0.0, sum_value_business_hours: 0.0 } }
events.each do |event|
metrics = metrics_for_event(event_name, event)
dimensions = dimensions_for_event(event)
metrics.each do |metric, metric_data|
dimensions.each do |dimension_type, dimension_id|
next if dimension_id.nil?
key = [dimension_type, dimension_id, metric]
aggregates[key][:count] += metric_data[:count]
aggregates[key][:sum_value] += metric_data[:sum_value].to_f
aggregates[key][:sum_value_business_hours] += metric_data[:sum_value_business_hours].to_f
end
end
end
aggregates
end
def dimensions_for_event(event)
{
'account' => @account.id,
'agent' => event.user_id,
'inbox' => event.inbox_id,
# Team rollups are still collected for validation/future use, but team reports
# currently stay on the raw path because reassignment breaks rollup parity.
'team' => event.conversation&.team_id
}
end
def metrics_for_event(event_name, event)
case event_name
when 'conversation_resolved'
{
'resolutions_count' => { count: 1, sum_value: 0, sum_value_business_hours: 0 },
'resolution_time' => { count: 1, sum_value: event.value, sum_value_business_hours: event.value_in_business_hours }
}
when 'first_response'
{ 'first_response' => { count: 1, sum_value: event.value, sum_value_business_hours: event.value_in_business_hours } }
when 'reply_time'
{ 'reply_time' => { count: 1, sum_value: event.value, sum_value_business_hours: event.value_in_business_hours } }
when 'conversation_bot_resolved'
{ 'bot_resolutions_count' => { count: 1, sum_value: 0, sum_value_business_hours: 0 } }
when 'conversation_bot_handoff'
{ 'bot_handoffs_count' => { count: 1, sum_value: 0, sum_value_business_hours: 0 } }
else
{}
end
end
def bulk_insert_rollups(rollup_rows)
# rubocop:disable Rails/SkipsModelValidations
ReportingEventsRollup.insert_all(rollup_rows)
# rubocop:enable Rails/SkipsModelValidations
end
end
@@ -1,149 +0,0 @@
module ReportingEvents::RollupService
def self.perform(reporting_event)
new(reporting_event).perform
end
def self.new(reporting_event)
Performer.new(reporting_event)
end
class Performer
def initialize(reporting_event)
@reporting_event = reporting_event
@account = reporting_event.account
end
def perform
return unless rollup_enabled?
# NOTE: Each event produces individual upserts per dimension x metric (up to 8 calls).
# If this becomes a bottleneck, batch into a single upsert_all call.
dimensions.each do |dimension_type, dimension_id|
next if dimension_id.nil?
metrics_for_event.each do |metric, metric_data|
upsert_rollup(dimension_type, dimension_id, metric, metric_data)
end
end
end
private
# NOTE: This is intentionally not gated by the reporting_events_rollup feature flag.
# Rollup data is collected for all accounts with a valid reporting timezone (soft toggle).
# The feature flag only controls the read path — whether reports query rollups or raw events.
def rollup_enabled?
@account.reporting_timezone.present? && ActiveSupport::TimeZone[@account.reporting_timezone].present?
end
def event_date
@event_date ||= @reporting_event.created_at.in_time_zone(@account.reporting_timezone).to_date
end
def dimensions
{
account: @account.id,
agent: @reporting_event.user_id,
inbox: @reporting_event.inbox_id,
# Team rollups are still collected for validation/future use, but team reports
# currently stay on the raw path because reassignment breaks rollup parity.
team: team_id
}
end
def team_id
return nil if @reporting_event.conversation_id.blank?
@team_id ||= @reporting_event.conversation&.team_id
end
def metrics_for_event
case @reporting_event.name
when 'conversation_resolved'
conversation_resolved_metrics
when 'first_response'
first_response_metrics
when 'reply_time'
reply_time_metrics
when 'conversation_bot_resolved'
bot_resolutions_metrics
when 'conversation_bot_handoff'
bot_handoffs_metrics
else
{}
end
end
def conversation_resolved_metrics
{
resolutions_count: { count: 1, sum_value: 0, sum_value_business_hours: 0 },
resolution_time: {
count: 1,
sum_value: @reporting_event.value,
sum_value_business_hours: @reporting_event.value_in_business_hours
}
}
end
def first_response_metrics
{
first_response: {
count: 1,
sum_value: @reporting_event.value,
sum_value_business_hours: @reporting_event.value_in_business_hours
}
}
end
def reply_time_metrics
{
reply_time: {
count: 1,
sum_value: @reporting_event.value,
sum_value_business_hours: @reporting_event.value_in_business_hours
}
}
end
def bot_resolutions_metrics
{
bot_resolutions_count: { count: 1, sum_value: 0, sum_value_business_hours: 0 }
}
end
def bot_handoffs_metrics
{
bot_handoffs_count: { count: 1, sum_value: 0, sum_value_business_hours: 0 }
}
end
def upsert_rollup(dimension_type, dimension_id, metric, metric_data)
# rubocop:disable Rails/SkipsModelValidations
ReportingEventsRollup.upsert(
rollup_attributes(dimension_type, dimension_id, metric, metric_data),
unique_by: [:account_id, :date, :dimension_type, :dimension_id, :metric],
on_duplicate: upsert_on_duplicate_sql
)
# rubocop:enable Rails/SkipsModelValidations
end
def rollup_attributes(dimension_type, dimension_id, metric, metric_data)
{
account_id: @account.id, date: event_date,
dimension_type: dimension_type, dimension_id: dimension_id, metric: metric,
count: metric_data[:count], sum_value: metric_data[:sum_value].to_f,
sum_value_business_hours: metric_data[:sum_value_business_hours].to_f,
created_at: Time.current, updated_at: Time.current
}
end
def upsert_on_duplicate_sql
Arel.sql(
'count = reporting_events_rollups.count + EXCLUDED.count, ' \
'sum_value = reporting_events_rollups.sum_value + EXCLUDED.sum_value, ' \
'sum_value_business_hours = reporting_events_rollups.sum_value_business_hours + EXCLUDED.sum_value_business_hours, ' \
'updated_at = EXCLUDED.updated_at'
)
end
end
end
@@ -0,0 +1,3 @@
<% if field.data %>
<%= field.datetime %>
<% end %>
-3
View File
@@ -242,6 +242,3 @@
display_name: Advanced Assignment
enabled: false
premium: true
- name: reporting_events_rollup
display_name: Reporting Events Rollup
enabled: false
+2 -1
View File
@@ -185,7 +185,8 @@ class Rack::Attack
###-----------------------------------------------###
## Prevent Abuse of Converstion Transcript APIs ###
throttle('/api/v1/accounts/:account_id/conversations/:conversation_id/transcript', limit: 30, period: 1.hour) do |req|
throttle('/api/v1/accounts/:account_id/conversations/:conversation_id/transcript',
limit: ENV.fetch('RATE_LIMIT_CONVERSATION_TRANSCRIPT', '1000').to_i, period: 1.hour) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/conversations/(?<conversation_id>\d+)/transcript}.match(req.path)
match_data[:account_id] if match_data.present?
end
-3
View File
@@ -48,9 +48,6 @@ en:
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
errors:
account:
reporting_timezone:
invalid: is not a valid timezone
validations:
presence: must not be blank
webhook:
@@ -1,39 +0,0 @@
class CreateReportingEventsRollup < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
create_rollups_table
add_rollups_indexes
end
private
def create_rollups_table
create_table :reporting_events_rollups do |t|
t.integer :account_id, null: false
t.date :date, null: false
t.string :dimension_type, null: false
t.bigint :dimension_id, null: false
t.string :metric, null: false
t.bigint :count, default: 0, null: false
t.float :sum_value, default: 0.0, null: false
t.float :sum_value_business_hours, default: 0.0, null: false
t.timestamps
end
end
def add_rollups_indexes
add_index :reporting_events_rollups,
[:account_id, :date, :dimension_type, :dimension_id, :metric],
unique: true, name: 'index_rollup_unique_key', algorithm: :concurrently
add_index :reporting_events_rollups,
[:account_id, :metric, :date],
name: 'index_rollup_timeseries', algorithm: :concurrently
add_index :reporting_events_rollups,
[:account_id, :dimension_type, :date],
name: 'index_rollup_summary', algorithm: :concurrently
end
end
-16
View File
@@ -1124,22 +1124,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do
t.index ["user_id"], name: "index_reporting_events_on_user_id"
end
create_table "reporting_events_rollups", force: :cascade do |t|
t.integer "account_id", null: false
t.date "date", null: false
t.string "dimension_type", null: false
t.bigint "dimension_id", null: false
t.string "metric", null: false
t.bigint "count", default: 0, null: false
t.float "sum_value", default: 0.0, null: false
t.float "sum_value_business_hours", default: 0.0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "date", "dimension_type", "dimension_id", "metric"], name: "index_rollup_unique_key", unique: true
t.index ["account_id", "dimension_type", "date"], name: "index_rollup_summary"
t.index ["account_id", "metric", "date"], name: "index_rollup_timeseries"
end
create_table "sla_events", force: :cascade do |t|
t.bigint "applied_sla_id", null: false
t.bigint "conversation_id", null: false
-251
View File
@@ -1,251 +0,0 @@
# frozen_string_literal: true
namespace :reporting_events_rollup do
desc 'Backfill rollup table from historical reporting events'
task backfill: :environment do
ReportingEventsRollupBackfill.new.run
end
end
class ReportingEventsRollupBackfill # rubocop:disable Metrics/ClassLength
def run
print_header
account = prompt_account
timezone = resolve_timezone(account)
first_event, last_event = discover_events(account)
start_date, end_date, total_days = resolve_date_range(account, timezone, first_event, last_event)
dry_run = prompt_dry_run?
print_plan(account, timezone, start_date, end_date, total_days, first_event, last_event, dry_run)
return if dry_run
confirm_and_execute(account, start_date, end_date, total_days)
end
private
def print_header
puts ''
puts color('=' * 70, :cyan)
puts color('Reporting Events Rollup Backfill', :bold, :cyan)
puts color('=' * 70, :cyan)
puts color('Plan:', :bold, :yellow)
puts '1. Ensure account.reporting_timezone is set before running this task.'
puts '2. Wait for the current day to end in that account timezone.'
puts '3. Run backfill for closed days only (today is skipped by default).'
puts '4. Verify parity, then enable reporting_events_rollup read path.'
puts ''
puts color('Note:', :bold, :yellow)
puts '- This task always uses account.reporting_timezone.'
puts '- Default range is first event day -> yesterday (in account timezone).'
puts ''
end
def prompt_account
print 'Enter Account ID: '
account_id = $stdin.gets.chomp
abort color('Error: Account ID is required', :red, :bold) if account_id.blank?
account = Account.find_by(id: account_id)
abort color("Error: Account with ID #{account_id} not found", :red, :bold) unless account
puts color("Found account: #{account.name}", :gray)
puts ''
account
end
def resolve_timezone(account)
timezone = account.reporting_timezone
abort color("Error: Account #{account.id} must have reporting_timezone set", :red, :bold) if timezone.blank?
abort color("Error: Account #{account.id} has invalid reporting_timezone '#{timezone}'", :red, :bold) if ActiveSupport::TimeZone[timezone].blank?
puts color("Using account reporting timezone: #{timezone}", :gray)
puts ''
timezone
end
def discover_events(account)
first_event = account.reporting_events.order(:created_at).first
last_event = account.reporting_events.order(:created_at).last
if first_event.nil?
puts ''
puts "No reporting events found for account #{account.id}"
puts 'Nothing to backfill.'
exit(0)
end
[first_event, last_event]
end
def resolve_date_range(account, timezone, first_event, last_event)
dates = discovered_dates(timezone, first_event, last_event)
print_discovered_date_range(account, dates)
build_date_range(dates)
end
def prompt_dry_run?
print 'Dry run? (y/N): '
input = $stdin.gets.chomp.downcase
puts ''
%w[y yes].include?(input)
end
# rubocop:disable Metrics/ParameterLists
def print_plan(account, timezone, start_date, end_date, total_days, first_event, last_event, dry_run)
zone = ActiveSupport::TimeZone[timezone]
print_plan_summary(account, timezone, start_date, end_date, total_days, zone, first_event, last_event, dry_run)
return unless dry_run
puts color("DRY RUN MODE: Would process #{total_days} days", :yellow, :bold)
puts "Would use account reporting_timezone '#{timezone}'"
puts 'Run without dry run to execute backfill'
end
# rubocop:enable Metrics/ParameterLists
def print_plan_summary(account, timezone, start_date, end_date, total_days, zone, first_event, last_event, dry_run) # rubocop:disable Metrics/ParameterLists
puts color('=' * 70, :cyan)
puts color('Backfill Plan Summary', :bold, :cyan)
puts color('=' * 70, :cyan)
puts "Account: #{account.name} (ID: #{account.id})"
puts "Timezone: #{timezone}"
puts "Date Range: #{start_date} to #{end_date} (#{total_days} days)"
puts "First Event: #{format_event_time(first_event, zone)}"
puts "Last Event: #{format_event_time(last_event, zone)}"
puts "Dry Run: #{dry_run ? 'YES (no data will be written)' : 'NO'}"
puts color('=' * 70, :cyan)
puts ''
end
def format_event_time(event, zone)
event.created_at.in_time_zone(zone).strftime('%Y-%m-%d %H:%M:%S %Z')
end
def discovered_dates(timezone, first_event, last_event)
tz = ActiveSupport::TimeZone[timezone]
discovered_start = first_event.created_at.in_time_zone(tz).to_date
discovered_end = last_event.created_at.in_time_zone(tz).to_date
{
discovered_start: discovered_start,
discovered_end: discovered_end,
discovered_days: (discovered_end - discovered_start).to_i + 1,
default_end: [discovered_end, Time.current.in_time_zone(tz).to_date - 1.day].min
}
end
def print_discovered_date_range(account, dates)
message = "Discovered date range: #{dates[:discovered_start]} to #{dates[:discovered_end]} " \
"(#{dates[:discovered_days]} days) [Account: #{account.name}]"
puts color(message, :gray)
puts color("Default end date (excluding today): #{dates[:default_end]}", :gray)
puts ''
end
def build_date_range(dates)
start_date = dates[:discovered_start]
end_date = dates[:default_end]
total_days = (end_date - start_date).to_i + 1
abort_no_closed_days if total_days <= 0
[start_date, end_date, total_days]
end
def abort_no_closed_days
puts 'No closed days available to backfill in the default range.'
exit(0)
end
def confirm_and_execute(account, start_date, end_date, total_days)
if total_days > 730
puts color("WARNING: Large backfill detected (#{total_days} days / #{(total_days / 365.0).round(1)} years)", :yellow, :bold)
puts ''
end
print 'Proceed with backfill? (y/N): '
confirm = $stdin.gets.chomp.downcase
abort 'Backfill cancelled' unless %w[y yes].include?(confirm)
puts ''
execute_backfill(account, start_date, end_date, total_days)
end
def execute_backfill(account, start_date, end_date, total_days)
puts 'Processing dates...'
puts ''
start_time = Time.current
days_processed = 0
(start_date..end_date).each do |date|
ReportingEvents::BackfillService.backfill_date(account, date)
days_processed += 1
percentage = (days_processed.to_f / total_days * 100).round(1)
print "\r#{date} | #{days_processed}/#{total_days} days | #{percentage}% "
$stdout.flush
end
print_success(account, days_processed, total_days, Time.current - start_time)
rescue StandardError => e
print_failure(e, days_processed, total_days)
end
def print_success(account, days_processed, _total_days, elapsed_time)
puts "\n\n"
puts color('=' * 70, :green)
puts color('BACKFILL COMPLETE', :bold, :green)
puts color('=' * 70, :green)
puts "Total Days Processed: #{days_processed}"
puts "Total Time: #{elapsed_time.round(2)} seconds"
puts "Average per Day: #{(elapsed_time / days_processed).round(3)} seconds"
puts ''
puts 'Next steps:'
puts "1. Enable feature flag: Account.find(#{account.id}).enable_features!('reporting_events_rollup')"
puts '2. Verify rollups in database:'
puts " ReportingEventsRollup.where(account_id: #{account.id}).count"
puts '3. Test reports to compare rollup vs raw performance'
puts color('=' * 70, :green)
end
def print_failure(error, days_processed, total_days)
puts "\n\n"
puts color('=' * 70, :red)
puts color('BACKFILL FAILED', :bold, :red)
puts color('=' * 70, :red)
print_error_details(error)
print_progress(days_processed, total_days)
exit(1)
end
def print_error_details(error)
puts color("Error: #{error.class.name} - #{error.message}", :red, :bold)
puts ''
puts 'Stack trace:'
puts error.backtrace.first(10).map { |line| " #{line}" }.join("\n")
puts ''
end
def print_progress(days_processed, total_days)
percentage = (days_processed.to_f / total_days * 100).round(1)
puts "Processed: #{days_processed}/#{total_days} days (#{percentage}%)"
puts color('=' * 70, :red)
end
ANSI_COLORS = {
reset: "\e[0m",
bold: "\e[1m",
red: "\e[31m",
green: "\e[32m",
yellow: "\e[33m",
cyan: "\e[36m",
gray: "\e[90m"
}.freeze
def color(text, *styles)
return text unless $stdout.tty?
codes = styles.filter_map { |style| ANSI_COLORS[style] }.join
"#{codes}#{text}#{ANSI_COLORS[:reset]}"
end
end
@@ -1,175 +0,0 @@
# frozen_string_literal: true
namespace :reporting_events_rollup do
desc 'Interactively set account.reporting_timezone and show recommended backfill run times'
task set_timezone: :environment do
ReportingEventsRollupTimezoneSetup.new.run
end
end
class ReportingEventsRollupTimezoneSetup
def run
print_header
account = prompt_account
print_current_timezone(account)
timezone = prompt_timezone
confirm_and_update(account, timezone)
print_next_steps(account, timezone)
end
private
def print_header
puts ''
puts color('=' * 70, :cyan)
puts color('Reporting Events Rollup Timezone Setup', :bold, :cyan)
puts color('=' * 70, :cyan)
puts color('Help:', :bold, :yellow)
puts '1. This task writes a valid account.reporting_timezone.'
puts '2. Backfill uses this timezone and skips today by default.'
puts '3. Run backfill only after the account timezone day closes.'
puts ''
end
def prompt_account
print 'Enter Account ID: '
account_id = $stdin.gets.chomp
abort color('Error: Account ID is required', :red, :bold) if account_id.blank?
account = Account.find_by(id: account_id)
abort color("Error: Account with ID #{account_id} not found", :red, :bold) unless account
puts color("Found account: #{account.name}", :gray)
puts ''
account
end
def print_current_timezone(account)
current_timezone = account.reporting_timezone.presence || '(not set)'
puts color("Current reporting_timezone: #{current_timezone}", :gray)
puts ''
end
def prompt_timezone
loop do
print 'Enter UTC offset to pick timezone (e.g., +5:30, -8, 0): '
offset_input = $stdin.gets.chomp
abort color('Error: UTC offset is required', :red, :bold) if offset_input.blank?
matching_zones = find_matching_zones(offset_input)
abort color("Error: No timezones found for offset '#{offset_input}'", :red, :bold) if matching_zones.empty?
display_matching_zones(matching_zones, offset_input)
timezone = select_timezone(matching_zones)
return timezone if timezone.present?
end
end
def find_matching_zones(offset_input)
normalized = offset_input.gsub(/^(?!\+|-)/, '+')
parts = normalized.split(':')
hours = parts[0].to_i
minutes = (parts[1] || '0').to_i
total_seconds = (hours * 3600) + (hours.negative? ? -minutes * 60 : minutes * 60)
ActiveSupport::TimeZone.all.select { |tz| tz.utc_offset == total_seconds }
end
def display_matching_zones(zones, offset_input)
puts ''
puts color("Timezones matching UTC#{offset_input}:", :yellow, :bold)
puts ''
zones.each_with_index do |tz, index|
puts " #{index + 1}. #{tz.name} (#{tz.tzinfo.identifier})"
end
puts ' 0. Re-enter UTC offset'
puts ''
end
def select_timezone(zones)
print "Select timezone (1-#{zones.size}, 0 to go back): "
selection = $stdin.gets.chomp.to_i
return if selection.zero?
abort color('Error: Invalid selection', :red, :bold) if selection < 1 || selection > zones.size
timezone = zones[selection - 1].tzinfo.identifier
puts color("Selected timezone: #{timezone}", :gray)
puts ''
timezone
end
def confirm_and_update(account, timezone)
print "Update account #{account.id} reporting_timezone to '#{timezone}'? (y/N): "
confirm = $stdin.gets.chomp.downcase
abort 'Timezone setup cancelled' unless %w[y yes].include?(confirm)
account.update!(reporting_timezone: timezone)
puts ''
puts color("Updated reporting_timezone for account '#{account.name}' to '#{timezone}'", :green, :bold)
puts ''
end
def print_next_steps(account, timezone)
run_times = recommended_run_times(timezone)
print_next_steps_header
print_next_steps_schedule(timezone, run_times)
print_next_steps_backfill(account)
puts color('=' * 70, :green)
end
def print_next_steps_header
puts color('=' * 70, :green)
puts color('Next Steps', :bold, :green)
puts color('=' * 70, :green)
end
def print_next_steps_schedule(timezone, run_times)
puts "1. Wait for today's day-boundary to pass in #{timezone}."
puts '2. Recommended earliest backfill start time:'
puts " - #{timezone}: #{format_time(run_times[:account_tz])}"
puts " - UTC: #{format_time(run_times[:utc])}"
puts " - IST: #{format_time(run_times[:ist])}"
puts " - PCT/PT: #{format_time(run_times[:pct])}"
end
def print_next_steps_backfill(account)
puts '3. Run backfill:'
puts ' bundle exec rake reporting_events_rollup:backfill'
puts "4. Backfill will use account.reporting_timezone and skip today by default for account #{account.id}."
end
def recommended_run_times(timezone)
account_zone = ActiveSupport::TimeZone[timezone]
next_day = Time.current.in_time_zone(account_zone).to_date + 1.day
account_time = account_zone.parse(next_day.to_s) + 30.minutes
{
account_tz: account_time,
utc: account_time.in_time_zone('UTC'),
ist: account_time.in_time_zone('Asia/Kolkata'),
pct: account_time.in_time_zone('Pacific Time (US & Canada)')
}
end
def format_time(time)
time.strftime('%Y-%m-%d %H:%M:%S %Z')
end
ANSI_COLORS = {
reset: "\e[0m",
bold: "\e[1m",
red: "\e[31m",
green: "\e[32m",
yellow: "\e[33m",
cyan: "\e[36m",
gray: "\e[90m"
}.freeze
def color(text, *styles)
return text unless $stdout.tty?
codes = styles.filter_map { |style| ANSI_COLORS[style] }.join
"#{codes}#{text}#{ANSI_COLORS[:reset]}"
end
end
@@ -1,327 +0,0 @@
require 'rails_helper'
describe V2::Reports::Concerns::RollupConditions do
let(:dummy_class) do
Class.new do
include V2::Reports::Concerns::RollupConditions
attr_accessor :account, :params
# Make private methods public for testing
public :metric_to_rollup_metric, :dimension_type_to_rollup, :timezone_matches_account?
end
end
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
let(:builder) { dummy_class.new }
# Compute the current offset dynamically so tests pass in both EST and EDT
let(:current_ny_offset) { ActiveSupport::TimeZone['America/New_York'].now.utc_offset / 3600.0 }
before do
builder.account = account
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
end
describe '#use_rollup?' do
let(:valid_params) do
{
metric: 'avg_resolution_time',
type: 'account',
group_by: 'day',
timezone_offset: current_ny_offset
}
end
context 'when all conditions pass' do
it 'returns true' do
builder.params = valid_params
expect(builder.use_rollup?).to be true
end
end
context 'when reporting_timezone is blank' do
it 'returns false' do
account.update!(reporting_timezone: nil)
builder.params = valid_params
expect(builder.use_rollup?).to be false
end
end
context 'when feature flag is disabled' do
it 'returns false' do
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(false)
builder.params = valid_params
expect(builder.use_rollup?).to be false
end
end
context 'when metric is not covered' do
it 'returns false for conversations_count' do
builder.params = valid_params.merge(metric: 'conversations_count')
expect(builder.use_rollup?).to be false
end
it 'returns false for incoming_messages_count' do
builder.params = valid_params.merge(metric: 'incoming_messages_count')
expect(builder.use_rollup?).to be false
end
it 'returns false for outgoing_messages_count' do
builder.params = valid_params.merge(metric: 'outgoing_messages_count')
expect(builder.use_rollup?).to be false
end
end
context 'when metric is covered' do
it 'returns true for avg_first_response_time' do
builder.params = valid_params.merge(metric: 'avg_first_response_time')
expect(builder.use_rollup?).to be true
end
it 'returns true for avg_resolution_time' do
builder.params = valid_params
expect(builder.use_rollup?).to be true
end
it 'returns true for reply_time' do
builder.params = valid_params.merge(metric: 'reply_time')
expect(builder.use_rollup?).to be true
end
it 'returns true for resolutions_count' do
builder.params = valid_params.merge(metric: 'resolutions_count')
expect(builder.use_rollup?).to be true
end
it 'returns true for bot_resolutions_count' do
builder.params = valid_params.merge(metric: 'bot_resolutions_count')
expect(builder.use_rollup?).to be true
end
it 'returns true for bot_handoffs_count' do
builder.params = valid_params.merge(metric: 'bot_handoffs_count')
expect(builder.use_rollup?).to be true
end
end
context 'when group_by is hourly' do
it 'returns false when group_by is hour' do
builder.params = valid_params.merge(group_by: 'hour')
expect(builder.use_rollup?).to be false
end
it 'returns true for day granularity' do
builder.params = valid_params
expect(builder.use_rollup?).to be true
end
it 'returns true for week granularity' do
builder.params = valid_params.merge(group_by: 'week')
expect(builder.use_rollup?).to be true
end
it 'returns true for month granularity' do
builder.params = valid_params.merge(group_by: 'month')
expect(builder.use_rollup?).to be true
end
it 'returns true for year granularity' do
builder.params = valid_params.merge(group_by: 'year')
expect(builder.use_rollup?).to be true
end
end
context 'when dimension is not supported' do
it 'returns false for label dimension' do
builder.params = valid_params.merge(type: 'label')
expect(builder.use_rollup?).to be false
end
it 'returns true for account dimension' do
builder.params = valid_params.merge(type: 'account')
expect(builder.use_rollup?).to be true
end
it 'returns true for agent dimension' do
builder.params = valid_params.merge(type: 'agent')
expect(builder.use_rollup?).to be true
end
it 'returns true for inbox dimension' do
builder.params = valid_params.merge(type: 'inbox')
expect(builder.use_rollup?).to be true
end
it 'returns false for team dimension' do
builder.params = valid_params.merge(type: 'team')
expect(builder.use_rollup?).to be false
end
end
context 'when timezone_offset does not match' do
it 'returns false when timezone_offset is UTC' do
builder.params = valid_params.merge(timezone_offset: 0)
expect(builder.use_rollup?).to be false
end
it 'returns false when timezone_offset is UTC+5:30' do
builder.params = valid_params.merge(timezone_offset: 5.5)
expect(builder.use_rollup?).to be false
end
it 'returns true when timezone_offset matches account timezone' do
builder.params = valid_params.merge(timezone_offset: current_ny_offset)
expect(builder.use_rollup?).to be true
end
it 'returns false when timezone_offset is blank' do
builder.params = valid_params.merge(timezone_offset: nil)
expect(builder.use_rollup?).to be false
end
end
context 'when used from BaseSummaryBuilder (no params[:metric])' do
it 'returns true because summary builders override metric_covered?' do
builder.params = { type: 'agent', timezone_offset: current_ny_offset }
# Without the override, this would return false since params[:metric] is blank
expect(builder.use_rollup?).to be false
# BaseSummaryBuilder overrides metric_covered? to always return true
summary_builder_class = Class.new(dummy_class) do
def metric_covered?
true
end
end
summary_builder = summary_builder_class.new
summary_builder.account = account
summary_builder.params = { type: 'agent', timezone_offset: current_ny_offset, group_by: 'day' }
expect(summary_builder.use_rollup?).to be true
end
end
end
describe '#metric_to_rollup_metric' do
it 'maps avg_resolution_time to resolution_time' do
builder.params = { metric: 'avg_resolution_time' }
expect(builder.metric_to_rollup_metric('avg_resolution_time')).to eq(:resolution_time)
end
it 'maps avg_first_response_time to first_response' do
builder.params = { metric: 'avg_first_response_time' }
expect(builder.metric_to_rollup_metric('avg_first_response_time')).to eq(:first_response)
end
it 'maps reply_time to reply_time' do
builder.params = { metric: 'reply_time' }
expect(builder.metric_to_rollup_metric('reply_time')).to eq(:reply_time)
end
it 'maps resolutions_count to resolutions_count' do
builder.params = { metric: 'resolutions_count' }
expect(builder.metric_to_rollup_metric('resolutions_count')).to eq(:resolutions_count)
end
it 'maps bot_resolutions_count to bot_resolutions_count' do
builder.params = { metric: 'bot_resolutions_count' }
expect(builder.metric_to_rollup_metric('bot_resolutions_count')).to eq(:bot_resolutions_count)
end
it 'maps bot_handoffs_count to bot_handoffs_count' do
builder.params = { metric: 'bot_handoffs_count' }
expect(builder.metric_to_rollup_metric('bot_handoffs_count')).to eq(:bot_handoffs_count)
end
end
describe '#dimension_type_to_rollup' do
it 'maps account type to account' do
builder.params = { type: 'account' }
expect(builder.dimension_type_to_rollup).to eq(:account)
end
it 'maps agent type to agent' do
builder.params = { type: 'agent' }
expect(builder.dimension_type_to_rollup).to eq(:agent)
end
it 'maps inbox type to inbox' do
builder.params = { type: 'inbox' }
expect(builder.dimension_type_to_rollup).to eq(:inbox)
end
it 'returns nil for team type' do
builder.params = { type: 'team' }
expect(builder.dimension_type_to_rollup).to be_nil
end
it 'returns nil for unsupported type' do
builder.params = { type: 'label' }
expect(builder.dimension_type_to_rollup).to be_nil
end
it 'returns nil when type is blank' do
builder.params = { type: nil }
expect(builder.dimension_type_to_rollup).to be_nil
end
end
describe '#timezone_matches_account?' do
it 'returns false when timezone_offset is blank' do
builder.params = { timezone_offset: nil }
expect(builder.timezone_matches_account?).to be false
end
it 'returns true when offset matches account timezone current offset' do
builder.params = { timezone_offset: current_ny_offset }
expect(builder.timezone_matches_account?).to be true
end
it 'returns false when offset does not match account timezone' do
# UTC+5:30 (IST) will never match America/New_York
builder.params = { timezone_offset: 5.5 }
expect(builder.timezone_matches_account?).to be false
end
it 'returns false when account timezone is not recognized' do
allow(account).to receive(:reporting_timezone).and_return('Invalid/Zone')
builder.params = { timezone_offset: current_ny_offset }
expect(builder.timezone_matches_account?).to be false
end
it 'works with fractional offsets like UTC+5:45' do
account.update!(reporting_timezone: 'Asia/Kathmandu')
kathmandu_offset = ActiveSupport::TimeZone['Asia/Kathmandu'].now.utc_offset / 3600.0
builder.params = { timezone_offset: kathmandu_offset }
expect(builder.timezone_matches_account?).to be true
end
it 'handles DST-aware comparison correctly' do
# Use a timezone with no DST (UTC) to have a stable control
account.update!(reporting_timezone: 'Etc/UTC')
builder.params = { timezone_offset: 0 }
expect(builder.timezone_matches_account?).to be true
# A non-zero offset should not match UTC
builder.params = { timezone_offset: -5 }
expect(builder.timezone_matches_account?).to be false
end
end
end
@@ -79,59 +79,6 @@ describe V2::Reports::Timeseries::AverageReportBuilder do
end
end
context 'when rollups are enabled' do
let(:timezone_offset) { '5.5' }
before do
account.update!(reporting_timezone: 'Chennai')
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
create(:reporting_events_rollup,
account: account,
date: (current_time - 1.week).to_date,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response',
count: 1,
sum_value: 93.0,
sum_value_business_hours: 30.0)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response',
count: 2,
sum_value: 180.0,
sum_value_business_hours: 30.0)
end
it 'preserves empty buckets in the timeseries' do
expected_timeseries = subject.send(:rollup_date_range).map do |date|
value = if date == (current_time - 1.week).to_date
93.0
elsif date == current_time.to_date
90.0
else
0
end
count = if date == (current_time - 1.week).to_date
1
elsif date == current_time.to_date
2
else
0
end
{ count: count, timestamp: date.in_time_zone('Chennai').to_i, value: value }
end
expect(subject.timeseries).to eq(expected_timeseries)
end
end
context 'when group_by is provided' do
let(:group_by) { 'week' }
@@ -160,45 +107,6 @@ describe V2::Reports::Timeseries::AverageReportBuilder do
)
end
end
context 'when weekly rollups are enabled' do
let(:group_by) { 'week' }
let(:timezone_offset) { '5.5' }
before do
account.update!(reporting_timezone: 'Chennai')
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date - 1.day,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response',
count: 1,
sum_value: 80.0,
sum_value_business_hours: 10.0)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response',
count: 1,
sum_value: 100.0,
sum_value_business_hours: 20.0)
end
it 'groups weeks using sunday boundaries' do
expect(subject.timeseries).to eq(
[
{ count: 0, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 0 },
{ count: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 90.0 }
]
)
end
end
end
context 'when the label filter is applied' do
@@ -262,20 +170,5 @@ describe V2::Reports::Timeseries::AverageReportBuilder do
expect(subject.aggregate_value).to eq 91.0
end
end
context 'when rollups are enabled and the agent does not exist' do
let(:filter_type) { :agent }
let(:filter_id) { '999999' }
let(:timezone_offset) { '0' }
before do
account.update!(reporting_timezone: 'Etc/UTC')
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
end
it 'raises record not found to preserve raw path behavior' do
expect { subject.aggregate_value }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end
@@ -14,16 +14,11 @@ describe V2::Reports::Timeseries::CountReportBuilder do
{
type: 'agent',
metric: 'resolutions_count',
since: since_time.beginning_of_day.to_i.to_s,
since: (current_time - 1.day).beginning_of_day.to_i.to_s,
until: current_time.end_of_day.to_i.to_s,
timezone_offset: timezone_offset,
group_by: group_by,
id: user.id.to_s
}
end
let(:group_by) { 'day' }
let(:since_time) { current_time - 1.day }
let(:timezone_offset) { nil }
before do
travel_to current_time
@@ -86,23 +81,6 @@ describe V2::Reports::Timeseries::CountReportBuilder do
expect(subject.aggregate_value).to eq(2)
end
context 'when rollups are enabled and the agent does not exist' do
let(:timezone_offset) { '0' }
let(:params) do
super().merge(id: '999999')
end
before do
account.update!(reporting_timezone: 'Etc/UTC')
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
end
it 'raises record not found to preserve raw path behavior' do
expect { subject.aggregate_value }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when querying account2' do
subject { described_class.new(account2, params) }
@@ -120,47 +98,6 @@ describe V2::Reports::Timeseries::CountReportBuilder do
total_count = result.sum { |r| r[:value] }
expect(total_count).to eq(2)
end
context 'when rollups are enabled and grouped by week' do
let(:group_by) { 'week' }
let(:current_time) { Time.zone.parse('2020-10-26 10:00:00 UTC') }
let(:since_time) { current_time - 1.week }
let(:timezone_offset) { '5.5' }
before do
account.update!(reporting_timezone: 'Chennai')
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date - 1.day,
dimension_type: 'agent',
dimension_id: user.id,
metric: 'resolutions_count',
count: 1,
sum_value: 0.0,
sum_value_business_hours: 0.0)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date,
dimension_type: 'agent',
dimension_id: user.id,
metric: 'resolutions_count',
count: 1,
sum_value: 0.0,
sum_value_business_hours: 0.0)
end
it 'groups weeks using sunday boundaries' do
expect(subject.timeseries).to eq(
[
{ value: 0, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i },
{ value: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i }
]
)
end
end
end
describe 'account isolation' do
@@ -81,6 +81,29 @@ RSpec.describe 'Accounts API', type: :request do
end
end
context 'when ENABLE_ACCOUNT_SIGNUP is stored as boolean false' do
before do
GlobalConfig.clear_cache
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false)
end
after do
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
GlobalConfig.clear_cache
end
it 'responds 404 on requests' do
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
post api_v1_accounts_url,
params: params,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do
it 'does not respond 404 on requests' do
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
@@ -45,10 +45,7 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::AgentSummaryBuilder).to have_received(:new).with(
account: account,
params: params.merge(type: :agent)
)
expect(V2::Reports::AgentSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(agent_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
@@ -99,10 +96,7 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::InboxSummaryBuilder).to have_received(:new).with(
account: account,
params: params.merge(type: :inbox)
)
expect(V2::Reports::InboxSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(inbox_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
@@ -153,10 +147,7 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::TeamSummaryBuilder).to have_received(:new).with(
account: account,
params: params.merge(type: :team)
)
expect(V2::Reports::TeamSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(team_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
@@ -94,6 +94,29 @@ RSpec.describe 'Accounts API', type: :request do
end
end
context 'when ENABLE_ACCOUNT_SIGNUP is stored as boolean false' do
before do
GlobalConfig.clear_cache
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false)
end
after do
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
GlobalConfig.clear_cache
end
it 'responds 404 on requests' do
params = { email: email, password: 'Password1!' }
post api_v2_accounts_url,
params: params,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do
let(:account_builder) { double }
let(:account) { create(:account) }
@@ -106,6 +106,26 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
end
end
it 'blocks signup if config is stored as boolean false' do
GlobalConfig.clear_cache
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false)
with_modified_env FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('does-not-exist-for-sure@example.com')
allow(email_validation_service).to receive(:perform).and_return(true)
get '/omniauth/google_oauth2/callback'
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
follow_redirect!
expect(response).to redirect_to(%r{/app/login\?error=no-account-found$})
end
ensure
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
GlobalConfig.clear_cache
end
it 'allows login' do
with_modified_env FRONTEND_URL: 'http://www.example.com' do
create(:user, email: 'test@example.com')
-12
View File
@@ -1,12 +0,0 @@
FactoryBot.define do
factory :reporting_events_rollup do
account
date { Date.current }
dimension_type { 'account' }
dimension_id { 1 }
metric { 'first_response' }
count { 1 }
sum_value { 100.0 }
sum_value_business_hours { 50.0 }
end
end
-15
View File
@@ -217,21 +217,6 @@ RSpec.describe Account do
expect(described_class.with_auto_resolve.pluck(:id)).not_to include(account.id)
end
end
context 'when reporting_timezone is set' do
it 'allows valid timezone names' do
account.reporting_timezone = 'America/New_York'
expect(account).to be_valid
end
it 'rejects invalid timezone names' do
account.reporting_timezone = 'Invalid/Timezone'
expect(account).not_to be_valid
expect(account.errors[:reporting_timezone]).to include(I18n.t('errors.account.reporting_timezone.invalid'))
end
end
end
describe 'captain_preferences' do
-182
View File
@@ -1,182 +0,0 @@
require 'rails_helper'
RSpec.describe ReportingEventsRollup do
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
describe 'enums' do
describe 'dimension_type enum' do
it 'stores dimension_type as string values' do
rollup = build(:reporting_events_rollup, dimension_type: 'account')
expect(rollup.dimension_type).to eq('account')
end
it 'has account dimension type' do
rollup = build(:reporting_events_rollup, dimension_type: 'account')
expect(rollup.account?).to be true
end
it 'has agent dimension type' do
rollup = build(:reporting_events_rollup, dimension_type: 'agent')
expect(rollup.agent?).to be true
end
it 'has inbox dimension type' do
rollup = build(:reporting_events_rollup, dimension_type: 'inbox')
expect(rollup.inbox?).to be true
end
it 'has team dimension type' do
rollup = build(:reporting_events_rollup, dimension_type: 'team')
expect(rollup.team?).to be true
end
end
describe 'metric enum' do
it 'stores metric as string values' do
rollup = build(:reporting_events_rollup, metric: 'first_response')
expect(rollup.metric).to eq('first_response')
end
it 'has resolutions_count metric' do
rollup = build(:reporting_events_rollup, metric: 'resolutions_count')
expect(rollup.resolutions_count?).to be true
end
it 'has first_response metric' do
rollup = build(:reporting_events_rollup, metric: 'first_response')
expect(rollup.first_response?).to be true
end
it 'has resolution_time metric' do
rollup = build(:reporting_events_rollup, metric: 'resolution_time')
expect(rollup.resolution_time?).to be true
end
it 'has reply_time metric' do
rollup = build(:reporting_events_rollup, metric: 'reply_time')
expect(rollup.reply_time?).to be true
end
it 'has bot_resolutions_count metric' do
rollup = build(:reporting_events_rollup, metric: 'bot_resolutions_count')
expect(rollup.bot_resolutions_count?).to be true
end
it 'has bot_handoffs_count metric' do
rollup = build(:reporting_events_rollup, metric: 'bot_handoffs_count')
expect(rollup.bot_handoffs_count?).to be true
end
end
end
describe 'validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:date) }
it { is_expected.to validate_presence_of(:dimension_type) }
it { is_expected.to validate_presence_of(:dimension_id) }
it { is_expected.to validate_presence_of(:metric) }
it { is_expected.to validate_numericality_of(:count).is_greater_than_or_equal_to(0) }
end
describe 'scopes' do
let(:account) { create(:account) }
let(:other_account) { create(:account) }
describe '.for_date_range' do
it 'filters by date range' do
create(:reporting_events_rollup, account: account, date: '2026-02-08'.to_date)
create(:reporting_events_rollup, account: account, date: '2026-02-10'.to_date)
create(:reporting_events_rollup, account: account, date: '2026-02-12'.to_date)
results = described_class.for_date_range('2026-02-10'.to_date, '2026-02-11'.to_date)
expect(results.count).to eq(1)
expect(results.first.date).to eq('2026-02-10'.to_date)
end
it 'returns empty array when no records match' do
create(:reporting_events_rollup, account: account, date: '2026-02-08'.to_date)
results = described_class.for_date_range('2026-02-20'.to_date, '2026-02-25'.to_date)
expect(results).to be_empty
end
end
describe '.for_dimension' do
it 'filters by dimension type and id' do
create(:reporting_events_rollup, account: account, dimension_type: 'account', dimension_id: 1)
create(:reporting_events_rollup, account: account, dimension_type: 'agent', dimension_id: 1)
create(:reporting_events_rollup, account: account, dimension_type: 'agent', dimension_id: 2)
results = described_class.for_dimension('agent', 1)
expect(results.count).to eq(1)
expect(results.first.dimension_type).to eq('agent')
expect(results.first.dimension_id).to eq(1)
end
it 'returns empty array when no records match' do
create(:reporting_events_rollup, account: account, dimension_type: 'account', dimension_id: 1)
results = described_class.for_dimension('agent', 1)
expect(results).to be_empty
end
end
describe '.for_metric' do
it 'filters by metric' do
create(:reporting_events_rollup, account: account, metric: 'first_response', dimension_id: 1)
create(:reporting_events_rollup, account: account, metric: 'resolution_time', dimension_id: 2)
create(:reporting_events_rollup, account: account, metric: 'first_response', dimension_id: 3)
results = described_class.for_metric('first_response')
expect(results.count).to eq(2)
expect(results.all? { |r| r.metric == 'first_response' }).to be true
end
it 'returns empty array when no records match' do
create(:reporting_events_rollup, account: account, metric: 'first_response')
results = described_class.for_metric('resolution_time')
expect(results).to be_empty
end
end
end
describe 'database schema' do
let(:account) { create(:account) }
let(:rollup) do
create(:reporting_events_rollup,
account: account,
date: '2026-02-10'.to_date,
dimension_type: 'account',
metric: 'first_response')
end
it 'has all required columns' do
expect(rollup).to have_attributes(
account_id: account.id,
date: '2026-02-10'.to_date,
dimension_type: 'account',
dimension_id: 1,
metric: 'first_response',
count: 1,
sum_value: 100.0,
sum_value_business_hours: 50.0
)
end
it 'stores enum values as strings in database' do
rollup
db_record = described_class.find(rollup.id)
expect(db_record.dimension_type).to eq('account')
expect(db_record.metric).to eq('first_response')
end
end
end
-74
View File
@@ -1,74 +0,0 @@
require 'rails_helper'
RSpec.describe 'Signup enforcement', type: :request do
let(:signup_config_name) { 'ENABLE_ACCOUNT_SIGNUP' }
before do
GlobalConfig.clear_cache
InstallationConfig.where(name: signup_config_name).delete_all
InstallationConfig.create!(name: signup_config_name, value: false, locked: false)
end
after do
InstallationConfig.where(name: signup_config_name).delete_all
GlobalConfig.clear_cache
OmniAuth.config.mock_auth[:google_oauth2] = nil
end
describe 'POST /api/v1/accounts' do
it 'blocks signup when the config is stored as boolean false' do
post api_v1_accounts_url,
params: {
account_name: 'verify',
user_full_name: 'Verify User',
email: "verify-#{SecureRandom.hex(4)}@example.com",
password: 'Password1!'
},
as: :json
expect(response).to have_http_status(:not_found)
end
end
describe 'POST /api/v2/accounts' do
it 'blocks signup when the config is stored as boolean false' do
post api_v2_accounts_url,
params: {
email: "verify-#{SecureRandom.hex(4)}@example.com",
password: 'Password1!'
},
as: :json
expect(response).to have_http_status(:not_found)
end
end
describe 'GET /omniauth/google_oauth2/callback' do
let(:email_validation_service) { instance_double(Account::SignUpEmailValidationService, perform: true) }
before do
OmniAuth.config.test_mode = true
allow(Account::SignUpEmailValidationService).to receive(:new).and_return(email_validation_service)
end
it 'redirects to no-account-found when the config is stored as boolean false' do
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
provider: 'google',
uid: '123545',
info: {
name: 'test',
email: "verify-#{SecureRandom.hex(4)}@example.com",
image: 'https://example.com/image.jpg'
}
)
with_modified_env FRONTEND_URL: 'http://www.example.com' do
get '/omniauth/google_oauth2/callback'
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
follow_redirect!
expect(response).to redirect_to(%r{/app/login\?error=no-account-found$})
end
end
end
end
@@ -1,45 +0,0 @@
require 'rails_helper'
describe ReportingEvents::BackfillService do
describe '.backfill_date' do
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
let(:date) { Date.new(2026, 2, 11) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
it 'treats nil metric values as zero during backfill' do
reporting_event = create(
:reporting_event,
account: account,
name: 'first_response',
value: 100,
value_in_business_hours: 50,
user: user,
inbox: inbox,
conversation: conversation,
created_at: Time.utc(2026, 2, 11, 15)
)
# Simulate a legacy row that already exists in the database with nil metrics.
# rubocop:disable Rails/SkipsModelValidations
reporting_event.update_columns(value: nil, value_in_business_hours: nil)
# rubocop:enable Rails/SkipsModelValidations
expect do
described_class.backfill_date(account, date)
end.not_to raise_error
rollup = ReportingEventsRollup.find_by!(
account_id: account.id,
date: date,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response'
)
expect(rollup.count).to eq(1)
expect(rollup.sum_value).to eq(0)
expect(rollup.sum_value_business_hours).to eq(0)
end
end
end
@@ -1,397 +0,0 @@
require 'rails_helper'
describe ReportingEvents::RollupService do
describe '.perform' do
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
let(:team) { create(:team, account: account) }
context 'when reporting_timezone is not set' do
before { account.update!(reporting_timezone: nil) }
it 'skips rollup creation' do
reporting_event = create(:reporting_event,
account: account,
name: 'conversation_resolved',
conversation: conversation)
expect do
described_class.perform(reporting_event)
end.not_to change(ReportingEventsRollup, :count)
end
end
context 'when reporting_timezone is invalid' do
it 'skips rollup creation' do
reporting_event = create(:reporting_event,
account: account,
name: 'conversation_resolved',
conversation: conversation)
allow(account).to receive(:reporting_timezone).and_return('Invalid/Zone')
expect do
described_class.perform(reporting_event)
end.not_to change(ReportingEventsRollup, :count)
end
end
context 'when reporting_timezone is set' do
describe 'conversation_resolved event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 1000,
value_in_business_hours: 500,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates rollup rows for all dimensions' do
described_class.perform(reporting_event)
# Account dimension
account_row = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
dimension_id: account.id,
metric: 'resolutions_count'
)
expect(account_row).to be_present
# Agent dimension
agent_row = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'agent',
dimension_id: user.id,
metric: 'resolutions_count'
)
expect(agent_row).to be_present
# Inbox dimension
inbox_row = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'inbox',
dimension_id: inbox.id,
metric: 'resolutions_count'
)
expect(inbox_row).to be_present
end
it 'creates correct metrics for conversation_resolved' do
described_class.perform(reporting_event)
resolutions_count = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'resolutions_count'
)
expect(resolutions_count.count).to eq(1)
expect(resolutions_count.sum_value).to eq(0)
expect(resolutions_count.sum_value_business_hours).to eq(0)
resolution_time = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'resolution_time'
)
expect(resolution_time.count).to eq(1)
expect(resolution_time.sum_value).to eq(1000)
expect(resolution_time.sum_value_business_hours).to eq(500)
end
it 'respects account timezone for date bucketing' do
# Event created at 2026-02-11 22:00 UTC
# In EST (UTC-5) that's 2026-02-11 17:00 (same day)
reporting_event.update!(created_at: '2026-02-11 22:00:00 UTC')
described_class.perform(reporting_event)
rollup = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account'
)
expect(rollup.date).to eq('2026-02-11'.to_date)
end
it 'handles timezone boundary crossing' do
# Event created at 2026-02-12 04:00 UTC
# In EST (UTC-5) that's 2026-02-11 23:00 (previous day)
reporting_event.update!(created_at: '2026-02-12 04:00:00 UTC')
described_class.perform(reporting_event)
rollup = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account'
)
expect(rollup.date).to eq('2026-02-11'.to_date)
end
end
describe 'first_response event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'first_response',
value: 500,
value_in_business_hours: 300,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates first_response metric' do
described_class.perform(reporting_event)
first_response = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'first_response'
)
expect(first_response).to be_present
expect(first_response.count).to eq(1)
expect(first_response.sum_value).to eq(500)
expect(first_response.sum_value_business_hours).to eq(300)
end
end
describe 'reply_time event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'reply_time',
value: 200,
value_in_business_hours: 100,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates reply_time metric' do
described_class.perform(reporting_event)
reply_time = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'reply_time'
)
expect(reply_time).to be_present
expect(reply_time.count).to eq(1)
expect(reply_time.sum_value).to eq(200)
expect(reply_time.sum_value_business_hours).to eq(100)
end
end
describe 'when metric values are nil' do
[
%w[conversation_resolved resolution_time],
%w[first_response first_response],
%w[reply_time reply_time]
].each do |event_name, metric|
it "treats nil values as zero for #{event_name}" do
reporting_event = create(:reporting_event,
account: account,
name: event_name,
value: 100,
value_in_business_hours: 50,
user: user,
inbox: inbox,
conversation: conversation)
reporting_event.assign_attributes(value: nil, value_in_business_hours: nil)
expect do
described_class.perform(reporting_event)
end.not_to raise_error
rollup = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: metric
)
expect(rollup).to be_present
expect(rollup.count).to eq(1)
expect(rollup.sum_value).to eq(0)
expect(rollup.sum_value_business_hours).to eq(0)
end
end
end
describe 'conversation_bot_resolved event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_bot_resolved',
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates bot_resolutions_count metric' do
described_class.perform(reporting_event)
bot_resolutions = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'bot_resolutions_count'
)
expect(bot_resolutions).to be_present
expect(bot_resolutions.count).to eq(1)
expect(bot_resolutions.sum_value).to eq(0)
end
end
describe 'conversation_bot_handoff event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_bot_handoff',
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates bot_handoffs_count metric' do
described_class.perform(reporting_event)
bot_handoffs = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'bot_handoffs_count'
)
expect(bot_handoffs).to be_present
expect(bot_handoffs.count).to eq(1)
expect(bot_handoffs.sum_value).to eq(0)
end
end
describe 'dimension handling' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 1000,
value_in_business_hours: 500,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'skips dimensions with nil IDs' do
# Create event without user (user_id will be nil)
reporting_event.update!(user_id: nil)
described_class.perform(reporting_event)
# Agent dimension should not be created
agent_rows = ReportingEventsRollup.where(
account_id: account.id,
dimension_type: 'agent'
)
expect(agent_rows).to be_empty
end
it 'loads team_id from conversation' do
conversation.update!(team_id: team.id)
described_class.perform(reporting_event)
team_row = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'team',
dimension_id: team.id
)
expect(team_row).to be_present
end
end
describe 'upsert behavior' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 1000,
value_in_business_hours: 500,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'increments count and sums on duplicate entries' do
# First call
described_class.perform(reporting_event)
resolution_time = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'resolution_time'
)
expect(resolution_time.count).to eq(1)
expect(resolution_time.sum_value).to eq(1000)
expect(resolution_time.sum_value_business_hours).to eq(500)
# Second call with same event
reporting_event2 = create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 500,
value_in_business_hours: 250,
user: user,
inbox: inbox,
conversation: conversation,
created_at: reporting_event.created_at)
described_class.perform(reporting_event2)
# Total should be incremented
resolution_time.reload
expect(resolution_time.count).to eq(2)
expect(resolution_time.sum_value).to eq(1500)
expect(resolution_time.sum_value_business_hours).to eq(750)
end
it 'does not create duplicate rollup rows' do
described_class.perform(reporting_event)
initial_count = ReportingEventsRollup.count
# Create another event with same date and dimensions
reporting_event2 = create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 500,
value_in_business_hours: 250,
user: user,
inbox: inbox,
conversation: conversation,
created_at: reporting_event.created_at)
described_class.perform(reporting_event2)
# Row count should remain the same
expect(ReportingEventsRollup.count).to eq(initial_count)
end
end
end
context 'when event name in unknown' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'unknown_event',
user: user,
inbox: inbox,
conversation: conversation)
end
it 'does not create any rollup rows' do
expect do
described_class.perform(reporting_event)
end.not_to change(ReportingEventsRollup, :count)
end
end
end
end
@@ -45,6 +45,11 @@ properties:
contact:
$ref: '#/components/schemas/contact_detail'
description: Contact details
assignee:
allOf:
- $ref: '#/components/schemas/agent'
description: The agent assigned to the conversation
nullable: true
agent_last_seen_at:
type: string
description: Timestamp when the agent last saw the conversation
@@ -32,6 +32,10 @@ properties:
type: string
description: ID of the message this is replying to
nullable: true
echo_id:
type: string
description: The echo ID of the message, used for deduplication
nullable: true
created_at:
type: integer
description: The timestamp when message was created
@@ -44,4 +48,32 @@ properties:
nullable: true
sender:
$ref: '#/components/schemas/contact_detail'
description: The sender of the message (only for incoming messages)
description: The sender of the message (only for incoming messages)
attachments:
type: array
description: The list of attachments associated with the message
items:
type: object
properties:
id:
type: number
description: The ID of the attachment
message_id:
type: number
description: The ID of the message
file_type:
type: string
enum: ["image", "video", "audio", "file", "location", "fallback", "share", "story_mention", "contact", "ig_reel"]
description: The type of the attached file
account_id:
type: number
description: The ID of the account
data_url:
type: string
description: The URL of the attached file
thumb_url:
type: string
description: The thumbnail URL of the attached file
file_size:
type: number
description: The size of the attached file in bytes
@@ -5,6 +5,17 @@ summary: Get messages
security:
- userApiKey: []
description: List all messages of a conversation
parameters:
- name: after
in: query
schema:
type: integer
description: Fetch messages after the message with this ID. Returns up to 100 messages in ascending order.
- name: before
in: query
schema:
type: integer
description: Fetch messages before the message with this ID. Returns up to 20 messages in ascending order.
responses:
'200':
description: Success
+81
View File
@@ -6119,6 +6119,24 @@
}
],
"description": "List all messages of a conversation",
"parameters": [
{
"name": "after",
"in": "query",
"schema": {
"type": "integer"
},
"description": "Fetch messages after the message with this ID. Returns up to 100 messages in ascending order."
},
{
"name": "before",
"in": "query",
"schema": {
"type": "integer"
},
"description": "Fetch messages before the message with this ID. Returns up to 20 messages in ascending order."
}
],
"responses": {
"200": {
"description": "Success",
@@ -12722,6 +12740,11 @@
}
}
},
"echo_id": {
"type": "string",
"description": "The echo ID of the message, used for deduplication",
"nullable": true
},
"created_at": {
"type": "integer",
"description": "The timestamp when message was created"
@@ -12737,6 +12760,55 @@
},
"sender": {
"$ref": "#/components/schemas/contact_detail"
},
"attachments": {
"type": "array",
"description": "The list of attachments associated with the message",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The ID of the attachment"
},
"message_id": {
"type": "number",
"description": "The ID of the message"
},
"file_type": {
"type": "string",
"enum": [
"image",
"video",
"audio",
"file",
"location",
"fallback",
"share",
"story_mention",
"contact",
"ig_reel"
],
"description": "The type of the attached file"
},
"account_id": {
"type": "number",
"description": "The ID of the account"
},
"data_url": {
"type": "string",
"description": "The URL of the attached file"
},
"thumb_url": {
"type": "string",
"description": "The thumbnail URL of the attached file"
},
"file_size": {
"type": "number",
"description": "The size of the attached file in bytes"
}
}
}
}
}
},
@@ -12805,6 +12877,15 @@
"contact": {
"$ref": "#/components/schemas/contact_detail"
},
"assignee": {
"allOf": [
{
"$ref": "#/components/schemas/agent"
}
],
"description": "The agent assigned to the conversation",
"nullable": true
},
"agent_last_seen_at": {
"type": "string",
"description": "Timestamp when the agent last saw the conversation",
@@ -4662,6 +4662,24 @@
}
],
"description": "List all messages of a conversation",
"parameters": [
{
"name": "after",
"in": "query",
"schema": {
"type": "integer"
},
"description": "Fetch messages after the message with this ID. Returns up to 100 messages in ascending order."
},
{
"name": "before",
"in": "query",
"schema": {
"type": "integer"
},
"description": "Fetch messages before the message with this ID. Returns up to 20 messages in ascending order."
}
],
"responses": {
"200": {
"description": "Success",
@@ -11229,6 +11247,11 @@
}
}
},
"echo_id": {
"type": "string",
"description": "The echo ID of the message, used for deduplication",
"nullable": true
},
"created_at": {
"type": "integer",
"description": "The timestamp when message was created"
@@ -11244,6 +11267,55 @@
},
"sender": {
"$ref": "#/components/schemas/contact_detail"
},
"attachments": {
"type": "array",
"description": "The list of attachments associated with the message",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The ID of the attachment"
},
"message_id": {
"type": "number",
"description": "The ID of the message"
},
"file_type": {
"type": "string",
"enum": [
"image",
"video",
"audio",
"file",
"location",
"fallback",
"share",
"story_mention",
"contact",
"ig_reel"
],
"description": "The type of the attached file"
},
"account_id": {
"type": "number",
"description": "The ID of the account"
},
"data_url": {
"type": "string",
"description": "The URL of the attached file"
},
"thumb_url": {
"type": "string",
"description": "The thumbnail URL of the attached file"
},
"file_size": {
"type": "number",
"description": "The size of the attached file in bytes"
}
}
}
}
}
},
@@ -11312,6 +11384,15 @@
"contact": {
"$ref": "#/components/schemas/contact_detail"
},
"assignee": {
"allOf": [
{
"$ref": "#/components/schemas/agent"
}
],
"description": "The agent assigned to the conversation",
"nullable": true
},
"agent_last_seen_at": {
"type": "string",
"description": "Timestamp when the agent last saw the conversation",
+63
View File
@@ -4882,6 +4882,11 @@
}
}
},
"echo_id": {
"type": "string",
"description": "The echo ID of the message, used for deduplication",
"nullable": true
},
"created_at": {
"type": "integer",
"description": "The timestamp when message was created"
@@ -4897,6 +4902,55 @@
},
"sender": {
"$ref": "#/components/schemas/contact_detail"
},
"attachments": {
"type": "array",
"description": "The list of attachments associated with the message",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The ID of the attachment"
},
"message_id": {
"type": "number",
"description": "The ID of the message"
},
"file_type": {
"type": "string",
"enum": [
"image",
"video",
"audio",
"file",
"location",
"fallback",
"share",
"story_mention",
"contact",
"ig_reel"
],
"description": "The type of the attached file"
},
"account_id": {
"type": "number",
"description": "The ID of the account"
},
"data_url": {
"type": "string",
"description": "The URL of the attached file"
},
"thumb_url": {
"type": "string",
"description": "The thumbnail URL of the attached file"
},
"file_size": {
"type": "number",
"description": "The size of the attached file in bytes"
}
}
}
}
}
},
@@ -4965,6 +5019,15 @@
"contact": {
"$ref": "#/components/schemas/contact_detail"
},
"assignee": {
"allOf": [
{
"$ref": "#/components/schemas/agent"
}
],
"description": "The agent assigned to the conversation",
"nullable": true
},
"agent_last_seen_at": {
"type": "string",
"description": "Timestamp when the agent last saw the conversation",
+63
View File
@@ -4297,6 +4297,11 @@
}
}
},
"echo_id": {
"type": "string",
"description": "The echo ID of the message, used for deduplication",
"nullable": true
},
"created_at": {
"type": "integer",
"description": "The timestamp when message was created"
@@ -4312,6 +4317,55 @@
},
"sender": {
"$ref": "#/components/schemas/contact_detail"
},
"attachments": {
"type": "array",
"description": "The list of attachments associated with the message",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The ID of the attachment"
},
"message_id": {
"type": "number",
"description": "The ID of the message"
},
"file_type": {
"type": "string",
"enum": [
"image",
"video",
"audio",
"file",
"location",
"fallback",
"share",
"story_mention",
"contact",
"ig_reel"
],
"description": "The type of the attached file"
},
"account_id": {
"type": "number",
"description": "The ID of the account"
},
"data_url": {
"type": "string",
"description": "The URL of the attached file"
},
"thumb_url": {
"type": "string",
"description": "The thumbnail URL of the attached file"
},
"file_size": {
"type": "number",
"description": "The size of the attached file in bytes"
}
}
}
}
}
},
@@ -4380,6 +4434,15 @@
"contact": {
"$ref": "#/components/schemas/contact_detail"
},
"assignee": {
"allOf": [
{
"$ref": "#/components/schemas/agent"
}
],
"description": "The agent assigned to the conversation",
"nullable": true
},
"agent_last_seen_at": {
"type": "string",
"description": "Timestamp when the agent last saw the conversation",
+63
View File
@@ -5058,6 +5058,11 @@
}
}
},
"echo_id": {
"type": "string",
"description": "The echo ID of the message, used for deduplication",
"nullable": true
},
"created_at": {
"type": "integer",
"description": "The timestamp when message was created"
@@ -5073,6 +5078,55 @@
},
"sender": {
"$ref": "#/components/schemas/contact_detail"
},
"attachments": {
"type": "array",
"description": "The list of attachments associated with the message",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The ID of the attachment"
},
"message_id": {
"type": "number",
"description": "The ID of the message"
},
"file_type": {
"type": "string",
"enum": [
"image",
"video",
"audio",
"file",
"location",
"fallback",
"share",
"story_mention",
"contact",
"ig_reel"
],
"description": "The type of the attached file"
},
"account_id": {
"type": "number",
"description": "The ID of the account"
},
"data_url": {
"type": "string",
"description": "The URL of the attached file"
},
"thumb_url": {
"type": "string",
"description": "The thumbnail URL of the attached file"
},
"file_size": {
"type": "number",
"description": "The size of the attached file in bytes"
}
}
}
}
}
},
@@ -5141,6 +5195,15 @@
"contact": {
"$ref": "#/components/schemas/contact_detail"
},
"assignee": {
"allOf": [
{
"$ref": "#/components/schemas/agent"
}
],
"description": "The agent assigned to the conversation",
"nullable": true
},
"agent_last_seen_at": {
"type": "string",
"description": "Timestamp when the agent last saw the conversation",