Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c49e7a4fb | ||
|
|
0c6d44c05d | ||
|
|
6e46be36c8 | ||
|
|
205e2a50fb | ||
|
|
5337d4624e | ||
|
|
b9ea227c3b | ||
|
|
c8dcdcba70 | ||
|
|
87f5af4caa | ||
|
|
dbe35252bc | ||
|
|
de8aa48b83 | ||
|
|
a14ff3dec8 | ||
|
|
3f088d751a | ||
|
|
43977a1927 | ||
|
|
a9cabad529 | ||
|
|
fdc326094a | ||
|
|
9a9398b386 | ||
|
|
dc0e5eb465 | ||
|
|
79218be5c4 | ||
|
|
8d9dd99012 | ||
|
|
9f376c43b5 | ||
|
|
824164852c | ||
|
|
8ea93ec73d | ||
|
|
28f58b3694 |
@@ -93,8 +93,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar
|
||||
java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json
|
||||
|
||||
# Bundle audit
|
||||
- run:
|
||||
|
||||
@@ -268,6 +268,7 @@ group :development, :test do
|
||||
gem 'seed_dump'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'simplecov', '>= 0.21', require: false
|
||||
gem 'skooma'
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
end
|
||||
|
||||
@@ -473,6 +473,12 @@ GEM
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
json_skooma (0.2.5)
|
||||
bigdecimal
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri-idna (~> 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
judoscale-rails (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
railties
|
||||
@@ -910,6 +916,9 @@ GEM
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
skooma (0.3.7)
|
||||
json_skooma (~> 0.2.5)
|
||||
zeitwerk (~> 2.6)
|
||||
slack-ruby-client (2.7.0)
|
||||
faraday (>= 2.0.1)
|
||||
faraday-mashify
|
||||
@@ -970,6 +979,7 @@ GEM
|
||||
unicode-emoji (4.0.4)
|
||||
uniform_notifier (1.17.0)
|
||||
uri (1.1.1)
|
||||
uri-idna (0.3.1)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
@@ -1143,6 +1153,7 @@ DEPENDENCIES
|
||||
sidekiq_alive
|
||||
simplecov (>= 0.21)
|
||||
simplecov_json_formatter
|
||||
skooma
|
||||
slack-ruby-client (~> 2.7.0)
|
||||
spring
|
||||
spring-watcher-listen
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,10 +18,18 @@ const newMessage = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const formatMessagesForApi = () => {
|
||||
return messages.value.map(message => ({
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
}));
|
||||
return messages.value.map(message => {
|
||||
const payload = {
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
};
|
||||
|
||||
if (message.sender === 'assistant' && message.agentName) {
|
||||
payload.agent_name = message.agentName;
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
};
|
||||
|
||||
const resetConversation = () => {
|
||||
@@ -62,6 +70,7 @@ const sendMessage = async () => {
|
||||
messages.value.push({
|
||||
content: data.response,
|
||||
sender: 'assistant',
|
||||
agentName: data.agent_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -78,7 +78,7 @@ const onSend = () => {
|
||||
<div
|
||||
v-else-if="isGeneratingContent"
|
||||
key="loading-state"
|
||||
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
|
||||
class="bg-n-iris-5 rounded min-h-[4.75rem] w-full mb-4 p-4 flex items-start"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<CaptainLoader class="text-n-iris-10 size-4" />
|
||||
|
||||
@@ -162,7 +162,8 @@ export default {
|
||||
this.inReplyTo?.id &&
|
||||
!this.isPrivate &&
|
||||
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
|
||||
!this.is360DialogWhatsAppChannel
|
||||
!this.is360DialogWhatsAppChannel &&
|
||||
!this.copilot.isActive.value
|
||||
);
|
||||
},
|
||||
showWhatsappTemplates() {
|
||||
@@ -1464,7 +1465,7 @@ export default {
|
||||
}
|
||||
|
||||
.reply-box__top {
|
||||
@apply relative py-0 px-4 -mt-px;
|
||||
@apply relative py-0 px-3 -mt-px;
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
|
||||
@@ -14,7 +14,7 @@ const emit = defineEmits(['dismiss']);
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
|
||||
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5"
|
||||
>
|
||||
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
|
||||
<div class="flex-grow gap-1 mt-px text-xs truncate">
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('useAutomation', () => {
|
||||
});
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const getterMap = {
|
||||
'agents/getAgents': agents,
|
||||
'agents/getVerifiedAgents': agents,
|
||||
'campaigns/getAllCampaigns': campaigns,
|
||||
'contacts/getContacts': contacts,
|
||||
'inboxes/getInboxes': inboxes,
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: mockLabels },
|
||||
'teams/getTeams': { value: mockTeams },
|
||||
'agents/getAgents': { value: mockAgents },
|
||||
'agents/getVerifiedAgents': { value: mockAgents },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: [] },
|
||||
'teams/getTeams': { value: [] },
|
||||
'agents/getAgents': { value: [] },
|
||||
'agents/getVerifiedAgents': { value: [] },
|
||||
});
|
||||
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
export default function useAutomationValues() {
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const agents = useMapGetter('agents/getAgents');
|
||||
const agents = useMapGetter('agents/getVerifiedAgents');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const contacts = useMapGetter('contacts/getContacts');
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useMacros = () => {
|
||||
|
||||
const labels = computed(() => getters['labels/getLabels'].value);
|
||||
const teams = computed(() => getters['teams/getTeams'].value);
|
||||
const agents = computed(() => getters['agents/getAgents'].value);
|
||||
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
||||
|
||||
/**
|
||||
* Get dropdown values based on the specified type
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -143,7 +143,7 @@ class Conversation < ApplicationRecord
|
||||
end
|
||||
|
||||
def last_incoming_message
|
||||
messages&.incoming&.last
|
||||
messages.where(account_id: account_id)&.incoming&.last
|
||||
end
|
||||
|
||||
def toggle_status
|
||||
|
||||
@@ -53,6 +53,7 @@ class Inbox < ApplicationRecord
|
||||
validates :out_of_office_message, length: { maximum: Limits::OUT_OF_OFFICE_MESSAGE_MAX_LENGTH }
|
||||
validates :greeting_message, length: { maximum: Limits::GREETING_MESSAGE_MAX_LENGTH }
|
||||
validate :ensure_valid_max_assignment_limit
|
||||
validate :ensure_inbox_limit, on: :create
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :portal, optional: true
|
||||
@@ -237,6 +238,16 @@ class Inbox < ApplicationRecord
|
||||
# overridden in enterprise/app/models/enterprise/inbox.rb
|
||||
end
|
||||
|
||||
def ensure_inbox_limit
|
||||
return unless account
|
||||
|
||||
inbox_limit = account.usage_limits[:inboxes]
|
||||
return if inbox_limit.nil?
|
||||
return unless account.inboxes.count >= inbox_limit.to_i
|
||||
|
||||
errors.add(:base, 'Account inbox limit exceeded. Upgrade to a higher plan')
|
||||
end
|
||||
|
||||
def delete_round_robin_agents
|
||||
::AutoAssignment::InboxRoundRobinService.new(inbox: self).clear_queue
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,7 +47,9 @@ class ActionService
|
||||
|
||||
@agent = @account.users.find_by(id: agent_ids)
|
||||
|
||||
@conversation.update!(assignee_id: @agent.id) if @agent.present?
|
||||
return unless @agent.present? && @agent.confirmed?
|
||||
|
||||
@conversation.update!(assignee_id: @agent.id)
|
||||
end
|
||||
|
||||
def remove_label(labels)
|
||||
|
||||
@@ -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 %>
|
||||
@@ -191,7 +191,6 @@
|
||||
- name: assignment_v2
|
||||
display_name: Assignment V2
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: twilio_content_templates
|
||||
display_name: Twilio Content Templates
|
||||
enabled: false
|
||||
@@ -242,6 +241,3 @@
|
||||
display_name: Advanced Assignment
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: reporting_events_rollup
|
||||
display_name: Reporting Events Rollup
|
||||
enabled: false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -24,10 +24,16 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def playground
|
||||
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
additional_message: params[:message_content],
|
||||
message_history: message_history
|
||||
)
|
||||
response = if captain_v2_enabled?
|
||||
Captain::Assistant::AgentRunnerService.new(assistant: @assistant, source: 'playground').generate_response(
|
||||
message_history: playground_message_history
|
||||
)
|
||||
else
|
||||
Captain::Llm::AssistantChatService.new(assistant: @assistant, source: 'playground').generate_response(
|
||||
additional_message: playground_params[:message_content],
|
||||
message_history: message_history
|
||||
)
|
||||
end
|
||||
|
||||
render json: response
|
||||
end
|
||||
@@ -64,10 +70,31 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def playground_params
|
||||
params.require(:assistant).permit(:message_content, message_history: [:role, :content])
|
||||
params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name])
|
||||
end
|
||||
|
||||
def message_history
|
||||
(playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } }
|
||||
(playground_params[:message_history] || []).map do |message|
|
||||
{
|
||||
role: message[:role],
|
||||
content: message[:content],
|
||||
agent_name: message[:agent_name]
|
||||
}.compact
|
||||
end
|
||||
end
|
||||
|
||||
def playground_message_history
|
||||
history = message_history
|
||||
current_message = playground_params[:message_content]
|
||||
return history if current_message.blank?
|
||||
|
||||
current_user_message = { role: 'user', content: current_message }
|
||||
return history if history.last == current_user_message
|
||||
|
||||
history + [current_user_message]
|
||||
end
|
||||
|
||||
def captain_v2_enabled?
|
||||
@assistant.account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,6 @@ module Captain::ChatHelper
|
||||
|
||||
def request_chat_completion
|
||||
log_chat_completion_request
|
||||
|
||||
chat = build_chat
|
||||
|
||||
add_messages_to_chat(chat)
|
||||
@@ -86,7 +85,8 @@ module Captain::ChatHelper
|
||||
temperature: temperature,
|
||||
metadata: {
|
||||
assistant_id: @assistant&.id,
|
||||
channel_type: resolved_channel_type
|
||||
channel_type: resolved_channel_type,
|
||||
source: @source
|
||||
}.compact
|
||||
}
|
||||
end
|
||||
@@ -130,7 +130,6 @@ module Captain::ChatHelper
|
||||
end
|
||||
|
||||
def log_chat_completion_request
|
||||
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion " \
|
||||
"for messages #{@messages} with #{@tools&.length || 0} tools")
|
||||
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, requesting completion for #{@messages} with #{@tools&.length || 0} tools")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -106,7 +106,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
scenarios: scenarios.enabled.map do |scenario|
|
||||
{
|
||||
title: scenario.title,
|
||||
key: scenario.title.parameterize.underscore,
|
||||
key: scenario.handoff_key,
|
||||
description: scenario.description
|
||||
}
|
||||
end,
|
||||
|
||||
@@ -24,6 +24,19 @@ class Captain::Scenario < ApplicationRecord
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
|
||||
# OpenAI enforces a 64-char limit on function names. The ai-agents gem
|
||||
# prepends "handoff_to_" (11 chars), so we keep a safety margin and cap
|
||||
# the full tool name to MAX_HANDOFF_TOOL_NAME_LENGTH (60 chars).
|
||||
# Format: "scenario_{id}_{slug}_agent" for persisted records (stable + readable),
|
||||
# and "scenario_draft_{slug}_agent" for unsaved records, with slug truncated
|
||||
# based on the available length budget.
|
||||
HANDOFF_TOOL_PREFIX = 'handoff_to_'.freeze
|
||||
HANDOFF_KEY_PREFIX = 'scenario'.freeze
|
||||
HANDOFF_KEY_SUFFIX = 'agent'.freeze
|
||||
MAX_HANDOFF_TOOL_NAME_LENGTH = 60
|
||||
MAX_AGENT_NAME_LENGTH = MAX_HANDOFF_TOOL_NAME_LENGTH - HANDOFF_TOOL_PREFIX.length
|
||||
MAX_HANDOFF_SLUG_LENGTH = 24
|
||||
|
||||
self.table_name = 'captain_scenarios'
|
||||
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
@@ -42,6 +55,10 @@ class Captain::Scenario < ApplicationRecord
|
||||
|
||||
before_save :resolve_tool_references
|
||||
|
||||
def handoff_key
|
||||
[handoff_id_key, compact_handoff_slug, HANDOFF_KEY_SUFFIX].compact.join('_')
|
||||
end
|
||||
|
||||
def prompt_context
|
||||
{
|
||||
title: title,
|
||||
@@ -56,7 +73,28 @@ class Captain::Scenario < ApplicationRecord
|
||||
private
|
||||
|
||||
def agent_name
|
||||
"#{title} Agent".parameterize(separator: '_')
|
||||
handoff_key
|
||||
end
|
||||
|
||||
def handoff_id_key
|
||||
return "#{HANDOFF_KEY_PREFIX}_#{id}" if id.present?
|
||||
|
||||
"#{HANDOFF_KEY_PREFIX}_draft"
|
||||
end
|
||||
|
||||
def compact_handoff_slug
|
||||
slug = title.to_s.parameterize(separator: '_').presence
|
||||
return nil if slug.blank?
|
||||
|
||||
max_slug_length = [MAX_HANDOFF_SLUG_LENGTH, dynamic_slug_max_length].min
|
||||
return nil if max_slug_length <= 0
|
||||
|
||||
slug.first(max_slug_length).sub(/_+\z/, '').presence
|
||||
end
|
||||
|
||||
def dynamic_slug_max_length
|
||||
# handoff_to_#{scenario_<id>_<slug>_agent}
|
||||
MAX_AGENT_NAME_LENGTH - handoff_id_key.length - HANDOFF_KEY_SUFFIX.length - 2
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
|
||||
@@ -19,11 +19,11 @@ class Captain::Assistant::AgentRunnerService
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
|
||||
def initialize(assistant:, conversation: nil, callbacks: {})
|
||||
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@callbacks = callbacks
|
||||
@source = source
|
||||
end
|
||||
|
||||
def generate_response(message_history: [])
|
||||
@@ -32,8 +32,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
process_agent_result(result)
|
||||
rescue StandardError => e
|
||||
# when running the agent runner service in a rake task, the conversation might not have an account associated
|
||||
# for regular production usage, it will run just fine
|
||||
# In rake/local runs, conversation may not be present, so account is optional here.
|
||||
ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception
|
||||
Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
@@ -128,6 +127,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
@@ -140,8 +140,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
|
||||
return unless @conversation.contact_inbox
|
||||
|
||||
state[:contact_inbox] =
|
||||
@conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
|
||||
state[:contact_inbox] = @conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
|
||||
end
|
||||
|
||||
def build_and_wire_agents
|
||||
@@ -180,6 +179,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type],
|
||||
format(ATTR_LANGFUSE_METADATA, 'source') => state[:source],
|
||||
ATTR_LANGFUSE_TRACE_INPUT => trace_input,
|
||||
ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input
|
||||
}.compact.transform_values(&:to_s)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
include Captain::ChatHelper
|
||||
|
||||
def initialize(assistant: nil, conversation_id: nil)
|
||||
def initialize(assistant: nil, conversation_id: nil, source: nil)
|
||||
super()
|
||||
|
||||
@assistant = assistant
|
||||
@conversation_id = conversation_id
|
||||
@source = source
|
||||
|
||||
@messages = [system_message]
|
||||
@response = ''
|
||||
|
||||
@@ -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
|
||||
+8
-8
@@ -36,8 +36,8 @@
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.7",
|
||||
"@chatwoot/utils": "^0.0.52",
|
||||
"@formkit/core": "^1.6.7",
|
||||
"@formkit/vue": "^1.6.7",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@iconify-json/fluent": "^1.2.32",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@scmmishra/pico-search": "0.6.0",
|
||||
"@sentry/vue": "^8.55.0",
|
||||
"@sindresorhus/slugify": "2.2.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/vue-table": "^8.20.5",
|
||||
"@twilio/voice-sdk": "^2.12.4",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
@@ -59,7 +59,7 @@
|
||||
"@vueuse/components": "^12.0.0",
|
||||
"@vueuse/core": "^12.0.0",
|
||||
"activestorage": "^5.2.6",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.13.6",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"chart.js": "~4.4.4",
|
||||
"color2k": "^2.0.2",
|
||||
@@ -68,7 +68,7 @@
|
||||
"countries-and-timezones": "^3.6.0",
|
||||
"date-fns": "2.21.1",
|
||||
"date-fns-tz": "^1.3.3",
|
||||
"dompurify": "3.2.4",
|
||||
"dompurify": "3.3.2",
|
||||
"flag-icons": "^7.2.3",
|
||||
"floating-vue": "^5.2.2",
|
||||
"highlight.js": "^11.10.0",
|
||||
@@ -99,7 +99,7 @@
|
||||
"vue": "^3.5.12",
|
||||
"vue-chartjs": "5.3.1",
|
||||
"vue-datepicker-next": "^1.0.3",
|
||||
"vue-dompurify-html": "^5.1.0",
|
||||
"vue-dompurify-html": "^5.3.0",
|
||||
"vue-i18n": "9.14.5",
|
||||
"vue-letter": "^0.2.1",
|
||||
"vue-router": "~4.4.5",
|
||||
@@ -111,7 +111,7 @@
|
||||
"wavesurfer.js": "7.8.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@egoist/tailwindcss-icons": "^1.9.0",
|
||||
"@egoist/tailwindcss-icons": "^1.9.2",
|
||||
"@histoire/plugin-vue": "0.17.15",
|
||||
"@iconify-json/logos": "^1.2.10",
|
||||
"@iconify-json/lucide": "^1.2.82",
|
||||
@@ -142,7 +142,7 @@
|
||||
"prettier": "^3.3.3",
|
||||
"prosemirror-model": "^1.22.3",
|
||||
"size-limit": "^8.2.4",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-ruby": "^5.0.0",
|
||||
"vitest": "3.0.5"
|
||||
|
||||
Generated
+131
-213
@@ -29,11 +29,11 @@ importers:
|
||||
specifier: ^0.0.52
|
||||
version: 0.0.52
|
||||
'@formkit/core':
|
||||
specifier: ^1.6.7
|
||||
version: 1.6.7
|
||||
specifier: ^1.7.2
|
||||
version: 1.7.2
|
||||
'@formkit/vue':
|
||||
specifier: ^1.6.7
|
||||
version: 1.6.7(tailwindcss@3.4.13)(vue@3.5.12(typescript@5.6.2))
|
||||
specifier: ^1.7.2
|
||||
version: 1.7.2(vue@3.5.12(typescript@5.6.2))
|
||||
'@hcaptcha/vue3-hcaptcha':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(vue@3.5.12(typescript@5.6.2))
|
||||
@@ -68,8 +68,8 @@ importers:
|
||||
specifier: 2.2.1
|
||||
version: 2.2.1
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.15
|
||||
version: 0.5.15(tailwindcss@3.4.13)
|
||||
specifier: ^0.5.19
|
||||
version: 0.5.19(tailwindcss@3.4.19)
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.20.5
|
||||
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
|
||||
@@ -98,8 +98,8 @@ importers:
|
||||
specifier: ^5.2.6
|
||||
version: 5.2.8
|
||||
axios:
|
||||
specifier: ^1.13.2
|
||||
version: 1.13.2
|
||||
specifier: ^1.13.6
|
||||
version: 1.13.6
|
||||
camelcase-keys:
|
||||
specifier: ^9.1.3
|
||||
version: 9.1.3
|
||||
@@ -125,8 +125,8 @@ importers:
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.8(date-fns@2.21.1)
|
||||
dompurify:
|
||||
specifier: 3.2.4
|
||||
version: 3.2.4
|
||||
specifier: 3.3.2
|
||||
version: 3.3.2
|
||||
flag-icons:
|
||||
specifier: ^7.2.3
|
||||
version: 7.2.3
|
||||
@@ -218,8 +218,8 @@ importers:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(vue@3.5.12(typescript@5.6.2))
|
||||
vue-dompurify-html:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0(vue@3.5.12(typescript@5.6.2))
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0(vue@3.5.12(typescript@5.6.2))
|
||||
vue-i18n:
|
||||
specifier: 9.14.5
|
||||
version: 9.14.5(vue@3.5.12(typescript@5.6.2))
|
||||
@@ -249,8 +249,8 @@ importers:
|
||||
version: 7.8.6
|
||||
devDependencies:
|
||||
'@egoist/tailwindcss-icons':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0(tailwindcss@3.4.13)
|
||||
specifier: ^1.9.2
|
||||
version: 1.9.2(tailwindcss@3.4.19)
|
||||
'@histoire/plugin-vue':
|
||||
specifier: 0.17.15
|
||||
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||
@@ -342,8 +342,8 @@ importers:
|
||||
specifier: ^8.2.4
|
||||
version: 8.2.6
|
||||
tailwindcss:
|
||||
specifier: ^3.4.13
|
||||
version: 3.4.13
|
||||
specifier: ^3.4.19
|
||||
version: 3.4.19
|
||||
vite:
|
||||
specifier: 5.4.21
|
||||
version: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
@@ -402,9 +402,6 @@ packages:
|
||||
'@antfu/install-pkg@1.1.0':
|
||||
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
|
||||
|
||||
'@antfu/utils@8.1.1':
|
||||
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
|
||||
|
||||
'@asamuzakjp/css-color@4.1.0':
|
||||
resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==}
|
||||
|
||||
@@ -700,8 +697,8 @@ packages:
|
||||
peerDependencies:
|
||||
postcss-selector-parser: ^6.0.10
|
||||
|
||||
'@egoist/tailwindcss-icons@1.9.0':
|
||||
resolution: {integrity: sha512-xWA9cUy6hzlK7Y6TaoRIcwmilSXiTJ8rbXcEdf9uht7yzDgw/yIgF4rThIQMrpD2Y2v4od51+r2y6Z7GStanDQ==}
|
||||
'@egoist/tailwindcss-icons@1.9.2':
|
||||
resolution: {integrity: sha512-I6XsSykmhu2cASg5Hp/ICLsJ/K/1aXPaSKjgbWaNp2xYnb4We/arWMmkhhV+9CglOFCUbqx0A3mM2kWV32ZIhw==}
|
||||
peerDependencies:
|
||||
tailwindcss: '*'
|
||||
|
||||
@@ -874,46 +871,35 @@ packages:
|
||||
'@floating-ui/utils@0.2.7':
|
||||
resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==}
|
||||
|
||||
'@formkit/core@1.6.7':
|
||||
resolution: {integrity: sha512-wEoWK7crcCPRV5KJfEGLjjIS+qwbuD8I5Ur0zTtKRQrdO4oRL6kVoubxQOpgnq1l8sWfcRY8Wpf22Wna2LD20Q==}
|
||||
'@formkit/core@1.7.2':
|
||||
resolution: {integrity: sha512-XDRVqkDtOziU3z44hdvgWEqGpiv6nTNeCmP8cnESKkr24a4keQuEwmfDVvibYV0ywyTMg6ylp2lyklCYeRHykQ==}
|
||||
|
||||
'@formkit/dev@1.6.7':
|
||||
resolution: {integrity: sha512-mMtkfvfkl1P1v0haizUE4DadalbG9/3m0ZymmMKKb0F3pojQJFtdfohy67ZQKtmzy4bamowHyEUr+XzLbKY2EA==}
|
||||
'@formkit/dev@1.7.2':
|
||||
resolution: {integrity: sha512-W38xbbFS4h4LTV22kUC6ZbPHBmlM2lVbAz5PSYU3SPaNc4FeXp4GTe4GzLEmcS82B+5L1zbSOgGB43kWZts7wA==}
|
||||
|
||||
'@formkit/i18n@1.6.7':
|
||||
resolution: {integrity: sha512-i9Mnc2XHCm2c10fppEIxdGv+jqOaixO22iFXX3xF+AkJnxtOzV5hP4f3/TeG+BNahGUq8vj+e1y+VMnjS6duxA==}
|
||||
'@formkit/i18n@1.7.2':
|
||||
resolution: {integrity: sha512-Zs6f+rtP2j2Nnt1HkNrL85WU6rtS1l1Q0BAQjCMGQPsbrppiL7/TZ5bNIrVm0DTntLgG3firDS7bNWAxPSwerQ==}
|
||||
|
||||
'@formkit/inputs@1.6.7':
|
||||
resolution: {integrity: sha512-VLxoAJn5VGOEXkGI499lju5Irnu12cu+spI1HL//46nlVkqnb1XcV0k0MK9K+hogz4fGmyZUvHf0lxU6dh7ZZQ==}
|
||||
'@formkit/inputs@1.7.2':
|
||||
resolution: {integrity: sha512-4xs7RJN8EFGctTCNRXBTvor2O8RvuEEK3q2Hl/RMU5lhhn5tmck90fkkeAr9o+rWRoSNiV8XgbLQArQ/B1IYPw==}
|
||||
|
||||
'@formkit/observer@1.6.7':
|
||||
resolution: {integrity: sha512-ei5z5ernNMKKiBuoRcFgEthhP1i+KKb02hsPsikLA3XehuoJdWIejn9AAq6jOEGbUMZ5XAAgFJcxzO5tnKuPnw==}
|
||||
'@formkit/observer@1.7.2':
|
||||
resolution: {integrity: sha512-KUr5mcu2SAeHOOK1FaMyFigtA8hkHLsUkaPFWpTC81j79o1AUjJppmfPaX6PbtlHLeMs2Zq8Qhp6VAhi7D2WJg==}
|
||||
|
||||
'@formkit/rules@1.6.7':
|
||||
resolution: {integrity: sha512-adzOuTvf6ghZbV0g0ZH9+MU9jfoF4DojBztAbqzFP/fT4d+WxhSHHlkWq6PU66fHPy3OH4DkWdx9trL1wGHuzQ==}
|
||||
'@formkit/rules@1.7.2':
|
||||
resolution: {integrity: sha512-2e5qKlXzmL9LAetncFp7xkHEX/UAJsU3bo1iL3idloH3HALf5fIdg3lgOKwCNDMdLjbfiKJ6lVwFaeFfFpcDKw==}
|
||||
|
||||
'@formkit/themes@1.6.7':
|
||||
resolution: {integrity: sha512-TIiWr4TMAFUg1pQz2E4GErfAhBv2Q2VbWlk6pqXPWI8UyPTjmcinEnCSIWDCX6FPPqiYShBnh8123nTO7pyvjA==}
|
||||
peerDependencies:
|
||||
tailwindcss: ^3.2.0
|
||||
unocss: 0.x.x
|
||||
windicss: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
tailwindcss:
|
||||
optional: true
|
||||
unocss:
|
||||
optional: true
|
||||
windicss:
|
||||
optional: true
|
||||
'@formkit/themes@1.7.2':
|
||||
resolution: {integrity: sha512-AaZHy7l9D44Ya3cQRqZ8RIpaTTsCjTB1gX13NlpqYSs6yG0yBTmJ7L7kW60bOGdhs/vmIwSel578EBwz98nybQ==}
|
||||
|
||||
'@formkit/utils@1.6.7':
|
||||
resolution: {integrity: sha512-aU3CDLzCkC5Dnx6iS3swbsIbys7E+2VOaLWFRnS7wk7kFa8EnENi67qc2E2KFE05RT4UCEAIYMAQY6wvek29gA==}
|
||||
'@formkit/utils@1.7.2':
|
||||
resolution: {integrity: sha512-bBF6alUBOqFfJHjVB95Vck0hp36vlw4QfFJxGfTO6BX68AEaFzzzabtpwfy0DbcHtwHh4Yn7l/rOWGxXEve+QQ==}
|
||||
|
||||
'@formkit/validation@1.6.7':
|
||||
resolution: {integrity: sha512-4wUUG+Pz3hPeiLccYiXAzsrF7SXk28PYAeHJeBngIv9K82ieljBJpvvuCJDyA6SeSMOvmbI92TG4wx4u5cDLOw==}
|
||||
'@formkit/validation@1.7.2':
|
||||
resolution: {integrity: sha512-JJsLP7AI/++VujdwtBeUcPhCqY3FXSKZ7hajT7Ow5Feab7JLqVPjxVMFbXDgEOLjq0ex1NX/I+EQHfm5sqMRsA==}
|
||||
|
||||
'@formkit/vue@1.6.7':
|
||||
resolution: {integrity: sha512-w3kjQD0lvtImyyTiy+fLaIZM/4r6sLDbpBIwke8ZA/d5orzNE96JPaloVKbH+HwCo6+z/1mclh1pW1S+7RgMcw==}
|
||||
'@formkit/vue@1.7.2':
|
||||
resolution: {integrity: sha512-jrvXl2ZhS6X5klTbP4N5zc0Dg37AjbAf8SCVoz7mfligVkmwrrF4SN3waMBcH+n9gqT+vrkOYb6bQXdvjUdnCQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.4.0
|
||||
|
||||
@@ -985,8 +971,8 @@ packages:
|
||||
'@iconify/types@2.0.0':
|
||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||
|
||||
'@iconify/utils@2.3.0':
|
||||
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
||||
'@iconify/utils@3.1.0':
|
||||
resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==}
|
||||
|
||||
'@intlify/core-base@9.14.2':
|
||||
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
|
||||
@@ -1291,10 +1277,10 @@ packages:
|
||||
peerDependencies:
|
||||
size-limit: 8.2.6
|
||||
|
||||
'@tailwindcss/typography@0.5.15':
|
||||
resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==}
|
||||
'@tailwindcss/typography@0.5.19':
|
||||
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20'
|
||||
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
|
||||
|
||||
'@tanstack/table-core@8.20.5':
|
||||
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
|
||||
@@ -1579,8 +1565,8 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acorn@8.15.0:
|
||||
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1712,8 +1698,8 @@ packages:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
axios@1.13.2:
|
||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||
axios@1.13.6:
|
||||
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
@@ -1922,9 +1908,6 @@ packages:
|
||||
confbox@0.1.8:
|
||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||
|
||||
confbox@0.2.2:
|
||||
resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
|
||||
|
||||
config-chain@1.1.13:
|
||||
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
|
||||
|
||||
@@ -2166,8 +2149,9 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.2.4:
|
||||
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
|
||||
dompurify@3.3.2:
|
||||
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
domutils@3.1.0:
|
||||
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
|
||||
@@ -2450,9 +2434,6 @@ packages:
|
||||
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
exsolve@1.0.8:
|
||||
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
||||
|
||||
extend-shallow@2.0.1:
|
||||
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2645,10 +2626,6 @@ packages:
|
||||
resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
globals@15.15.0:
|
||||
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
globalthis@1.0.3:
|
||||
resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2796,8 +2773,8 @@ packages:
|
||||
resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immutable@4.3.7:
|
||||
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
|
||||
immutable@4.3.8:
|
||||
resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==}
|
||||
|
||||
import-fresh@3.3.0:
|
||||
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
||||
@@ -3001,6 +2978,10 @@ packages:
|
||||
resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==}
|
||||
hasBin: true
|
||||
|
||||
jiti@1.21.7:
|
||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||
hasBin: true
|
||||
|
||||
js-beautify@1.15.1:
|
||||
resolution: {integrity: sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -3077,9 +3058,6 @@ packages:
|
||||
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
kolorist@1.8.0:
|
||||
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
||||
|
||||
launch-editor@2.9.1:
|
||||
resolution: {integrity: sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==}
|
||||
|
||||
@@ -3097,8 +3075,8 @@ packages:
|
||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lilconfig@3.1.2:
|
||||
resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==}
|
||||
lilconfig@3.1.3:
|
||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
@@ -3131,10 +3109,6 @@ packages:
|
||||
lit@2.2.6:
|
||||
resolution: {integrity: sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==}
|
||||
|
||||
local-pkg@1.1.2:
|
||||
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3147,12 +3121,6 @@ packages:
|
||||
resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
lodash.castarray@4.4.0:
|
||||
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -3308,8 +3276,8 @@ packages:
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
mlly@1.8.0:
|
||||
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
|
||||
mlly@1.8.1:
|
||||
resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==}
|
||||
|
||||
mpd-parser@0.21.0:
|
||||
resolution: {integrity: sha512-NbpMJ57qQzFmfCiP1pbL7cGMbVTD0X1hqNgL0VYP1wLlZXLf/HtmvQpNkOA1AHkPVeGQng+7/jEtSvNUzV7Gdg==}
|
||||
@@ -3606,9 +3574,6 @@ packages:
|
||||
pkg-types@1.3.1:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
pkg-types@2.3.0:
|
||||
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -3915,9 +3880,6 @@ packages:
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
querystringify@2.2.0:
|
||||
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
|
||||
|
||||
@@ -4269,8 +4231,8 @@ packages:
|
||||
resolution: {integrity: sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
tailwindcss@3.4.13:
|
||||
resolution: {integrity: sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==}
|
||||
tailwindcss@3.4.19:
|
||||
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -4432,8 +4394,8 @@ packages:
|
||||
uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
ufo@1.6.1:
|
||||
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
|
||||
ufo@1.6.3:
|
||||
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
|
||||
|
||||
unbox-primitive@1.0.2:
|
||||
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
||||
@@ -4622,10 +4584,10 @@ packages:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-dompurify-html@5.1.0:
|
||||
resolution: {integrity: sha512-616o2/PBdOLM2bwlRWLdzeEC9NerLkwiudqNgaIJ5vBQWXec+u7Kuzh+45DtQQrids67s4pHnTnJZLVfyPMxbA==}
|
||||
vue-dompurify-html@5.3.0:
|
||||
resolution: {integrity: sha512-HJQGBHbfSPcb6Mu97McdKbX7TqRHZa6Ji8OCpCNyuHca5QvQZ8IiuwghFPSO8OkSQfqXPNPKFMZdCOrnGGmOSQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
vue: ^3.4.36
|
||||
|
||||
vue-eslint-parser@9.4.3:
|
||||
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
|
||||
@@ -4944,8 +4906,6 @@ snapshots:
|
||||
package-manager-detector: 1.6.0
|
||||
tinyexec: 1.0.2
|
||||
|
||||
'@antfu/utils@8.1.1': {}
|
||||
|
||||
'@asamuzakjp/css-color@4.1.0':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
@@ -5262,12 +5222,10 @@ snapshots:
|
||||
dependencies:
|
||||
postcss-selector-parser: 6.1.1
|
||||
|
||||
'@egoist/tailwindcss-icons@1.9.0(tailwindcss@3.4.13)':
|
||||
'@egoist/tailwindcss-icons@1.9.2(tailwindcss@3.4.19)':
|
||||
dependencies:
|
||||
'@iconify/utils': 2.3.0
|
||||
tailwindcss: 3.4.13
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
'@iconify/utils': 3.1.0
|
||||
tailwindcss: 3.4.19
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
optional: true
|
||||
@@ -5385,67 +5343,61 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.7': {}
|
||||
|
||||
'@formkit/core@1.6.7':
|
||||
'@formkit/core@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/utils': 1.7.2
|
||||
|
||||
'@formkit/dev@1.6.7':
|
||||
'@formkit/dev@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
|
||||
'@formkit/i18n@1.6.7':
|
||||
'@formkit/i18n@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/validation': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
'@formkit/validation': 1.7.2
|
||||
|
||||
'@formkit/inputs@1.6.7':
|
||||
'@formkit/inputs@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
|
||||
'@formkit/observer@1.6.7':
|
||||
'@formkit/observer@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
|
||||
'@formkit/rules@1.6.7':
|
||||
'@formkit/rules@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/validation': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
'@formkit/validation': 1.7.2
|
||||
|
||||
'@formkit/themes@1.6.7(tailwindcss@3.4.13)':
|
||||
'@formkit/themes@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
optionalDependencies:
|
||||
tailwindcss: 3.4.13
|
||||
'@formkit/core': 1.7.2
|
||||
|
||||
'@formkit/utils@1.6.7': {}
|
||||
'@formkit/utils@1.7.2': {}
|
||||
|
||||
'@formkit/validation@1.6.7':
|
||||
'@formkit/validation@1.7.2':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/observer': 1.6.7
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/observer': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
|
||||
'@formkit/vue@1.6.7(tailwindcss@3.4.13)(vue@3.5.12(typescript@5.6.2))':
|
||||
'@formkit/vue@1.7.2(vue@3.5.12(typescript@5.6.2))':
|
||||
dependencies:
|
||||
'@formkit/core': 1.6.7
|
||||
'@formkit/dev': 1.6.7
|
||||
'@formkit/i18n': 1.6.7
|
||||
'@formkit/inputs': 1.6.7
|
||||
'@formkit/observer': 1.6.7
|
||||
'@formkit/rules': 1.6.7
|
||||
'@formkit/themes': 1.6.7(tailwindcss@3.4.13)
|
||||
'@formkit/utils': 1.6.7
|
||||
'@formkit/validation': 1.6.7
|
||||
'@formkit/core': 1.7.2
|
||||
'@formkit/dev': 1.7.2
|
||||
'@formkit/i18n': 1.7.2
|
||||
'@formkit/inputs': 1.7.2
|
||||
'@formkit/observer': 1.7.2
|
||||
'@formkit/rules': 1.7.2
|
||||
'@formkit/themes': 1.7.2
|
||||
'@formkit/utils': 1.7.2
|
||||
'@formkit/validation': 1.7.2
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
transitivePeerDependencies:
|
||||
- tailwindcss
|
||||
- unocss
|
||||
- windicss
|
||||
|
||||
'@hcaptcha/vue3-hcaptcha@1.3.0(vue@3.5.12(typescript@5.6.2))':
|
||||
dependencies:
|
||||
@@ -5549,18 +5501,11 @@ snapshots:
|
||||
|
||||
'@iconify/types@2.0.0': {}
|
||||
|
||||
'@iconify/utils@2.3.0':
|
||||
'@iconify/utils@3.1.0':
|
||||
dependencies:
|
||||
'@antfu/install-pkg': 1.1.0
|
||||
'@antfu/utils': 8.1.1
|
||||
'@iconify/types': 2.0.0
|
||||
debug: 4.4.3
|
||||
globals: 15.15.0
|
||||
kolorist: 1.8.0
|
||||
local-pkg: 1.1.2
|
||||
mlly: 1.8.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
mlly: 1.8.1
|
||||
|
||||
'@intlify/core-base@9.14.2':
|
||||
dependencies:
|
||||
@@ -5847,13 +5792,10 @@ snapshots:
|
||||
semver: 7.5.3
|
||||
size-limit: 8.2.6
|
||||
|
||||
'@tailwindcss/typography@0.5.15(tailwindcss@3.4.13)':
|
||||
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.19)':
|
||||
dependencies:
|
||||
lodash.castarray: 4.4.0
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.merge: 4.6.2
|
||||
postcss-selector-parser: 6.0.10
|
||||
tailwindcss: 3.4.13
|
||||
tailwindcss: 3.4.19
|
||||
|
||||
'@tanstack/table-core@8.20.5': {}
|
||||
|
||||
@@ -6230,7 +6172,7 @@ snapshots:
|
||||
|
||||
acorn@8.14.0: {}
|
||||
|
||||
acorn@8.15.0: {}
|
||||
acorn@8.16.0: {}
|
||||
|
||||
activestorage@5.2.8:
|
||||
dependencies:
|
||||
@@ -6391,7 +6333,7 @@ snapshots:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.0.0
|
||||
|
||||
axios@1.13.2:
|
||||
axios@1.13.6:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 4.0.5
|
||||
@@ -6633,8 +6575,6 @@ snapshots:
|
||||
|
||||
confbox@0.1.8: {}
|
||||
|
||||
confbox@0.2.2: {}
|
||||
|
||||
config-chain@1.1.13:
|
||||
dependencies:
|
||||
ini: 1.3.8
|
||||
@@ -6850,7 +6790,7 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.2.4:
|
||||
dompurify@3.3.2:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
@@ -7270,8 +7210,6 @@ snapshots:
|
||||
|
||||
expect-type@1.1.0: {}
|
||||
|
||||
exsolve@1.0.8: {}
|
||||
|
||||
extend-shallow@2.0.1:
|
||||
dependencies:
|
||||
is-extendable: 0.1.1
|
||||
@@ -7487,8 +7425,6 @@ snapshots:
|
||||
|
||||
globals@15.14.0: {}
|
||||
|
||||
globals@15.15.0: {}
|
||||
|
||||
globalthis@1.0.3:
|
||||
dependencies:
|
||||
define-properties: 1.2.0
|
||||
@@ -7684,7 +7620,7 @@ snapshots:
|
||||
|
||||
ignore@6.0.2: {}
|
||||
|
||||
immutable@4.3.7:
|
||||
immutable@4.3.8:
|
||||
optional: true
|
||||
|
||||
import-fresh@3.3.0:
|
||||
@@ -7874,6 +7810,8 @@ snapshots:
|
||||
|
||||
jiti@1.21.6: {}
|
||||
|
||||
jiti@1.21.7: {}
|
||||
|
||||
js-beautify@1.15.1:
|
||||
dependencies:
|
||||
config-chain: 1.1.13
|
||||
@@ -7990,8 +7928,6 @@ snapshots:
|
||||
|
||||
kind-of@6.0.3: {}
|
||||
|
||||
kolorist@1.8.0: {}
|
||||
|
||||
launch-editor@2.9.1:
|
||||
dependencies:
|
||||
picocolors: 1.1.0
|
||||
@@ -8008,7 +7944,7 @@ snapshots:
|
||||
|
||||
lilconfig@2.1.0: {}
|
||||
|
||||
lilconfig@3.1.2: {}
|
||||
lilconfig@3.1.3: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
@@ -8059,12 +7995,6 @@ snapshots:
|
||||
lit-element: 3.3.3
|
||||
lit-html: 2.8.0
|
||||
|
||||
local-pkg@1.1.2:
|
||||
dependencies:
|
||||
mlly: 1.8.0
|
||||
pkg-types: 2.3.0
|
||||
quansync: 0.2.11
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
@@ -8077,10 +8007,6 @@ snapshots:
|
||||
dependencies:
|
||||
p-locate: 6.0.0
|
||||
|
||||
lodash.castarray@4.4.0: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.truncate@4.4.2: {}
|
||||
@@ -8235,12 +8161,12 @@ snapshots:
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mlly@1.8.0:
|
||||
mlly@1.8.1:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
acorn: 8.16.0
|
||||
pathe: 2.0.3
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.1
|
||||
ufo: 1.6.3
|
||||
|
||||
mpd-parser@0.21.0:
|
||||
dependencies:
|
||||
@@ -8511,13 +8437,7 @@ snapshots:
|
||||
pkg-types@1.3.1:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
mlly: 1.8.0
|
||||
pathe: 2.0.3
|
||||
|
||||
pkg-types@2.3.0:
|
||||
dependencies:
|
||||
confbox: 0.2.2
|
||||
exsolve: 1.0.8
|
||||
mlly: 1.8.1
|
||||
pathe: 2.0.3
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
@@ -8634,7 +8554,7 @@ snapshots:
|
||||
|
||||
postcss-load-config@4.0.2(postcss@8.4.47):
|
||||
dependencies:
|
||||
lilconfig: 3.1.2
|
||||
lilconfig: 3.1.3
|
||||
yaml: 2.5.1
|
||||
optionalDependencies:
|
||||
postcss: 8.4.47
|
||||
@@ -8889,8 +8809,6 @@ snapshots:
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
querystringify@2.2.0: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
@@ -9035,7 +8953,7 @@ snapshots:
|
||||
sass@1.79.3:
|
||||
dependencies:
|
||||
chokidar: 4.0.3
|
||||
immutable: 4.3.7
|
||||
immutable: 4.3.8
|
||||
source-map-js: 1.2.1
|
||||
optional: true
|
||||
|
||||
@@ -9289,7 +9207,7 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
tailwindcss@3.4.13:
|
||||
tailwindcss@3.4.19:
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
arg: 5.0.2
|
||||
@@ -9299,12 +9217,12 @@ snapshots:
|
||||
fast-glob: 3.3.2
|
||||
glob-parent: 6.0.2
|
||||
is-glob: 4.0.3
|
||||
jiti: 1.21.6
|
||||
lilconfig: 2.1.0
|
||||
jiti: 1.21.7
|
||||
lilconfig: 3.1.3
|
||||
micromatch: 4.0.8
|
||||
normalize-path: 3.0.0
|
||||
object-hash: 3.0.0
|
||||
picocolors: 1.1.0
|
||||
picocolors: 1.1.1
|
||||
postcss: 8.4.47
|
||||
postcss-import: 15.1.0(postcss@8.4.47)
|
||||
postcss-js: 4.0.1(postcss@8.4.47)
|
||||
@@ -9324,7 +9242,7 @@ snapshots:
|
||||
terser@5.33.0:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
acorn: 8.15.0
|
||||
acorn: 8.16.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
optional: true
|
||||
@@ -9487,7 +9405,7 @@ snapshots:
|
||||
|
||||
uc.micro@2.1.0: {}
|
||||
|
||||
ufo@1.6.1: {}
|
||||
ufo@1.6.3: {}
|
||||
|
||||
unbox-primitive@1.0.2:
|
||||
dependencies:
|
||||
@@ -9672,9 +9590,9 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-dompurify-html@5.1.0(vue@3.5.12(typescript@5.6.2)):
|
||||
vue-dompurify-html@5.3.0(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
dompurify: 3.2.4
|
||||
dompurify: 3.3.2
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-eslint-parser@9.4.3(eslint@8.57.0):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,7 @@ RSpec.describe 'Agents API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.parsed_body.size).to eq(account.users.count)
|
||||
end
|
||||
|
||||
@@ -122,6 +123,7 @@ RSpec.describe 'Agents API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(other_agent.reload.name).to eq(params[:name])
|
||||
end
|
||||
|
||||
@@ -171,6 +173,7 @@ RSpec.describe 'Agents API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.parsed_body['email']).to eq(params[:email])
|
||||
expect(account.users.last.name).to eq('NewUser')
|
||||
end
|
||||
|
||||
@@ -45,6 +45,7 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
response_body = response.parsed_body
|
||||
contact_emails = response_body['payload'].pluck('email')
|
||||
contact_inboxes_source_ids = response_body['payload'].flat_map { |c| c['contact_inboxes'].pluck('source_id') }
|
||||
@@ -331,6 +332,7 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.body).to include(contact2.email)
|
||||
expect(response.body).not_to include(contact1.email)
|
||||
end
|
||||
@@ -443,6 +445,7 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.body).to include(contact2.email)
|
||||
expect(response.body).to include(contact1.email)
|
||||
end
|
||||
@@ -497,6 +500,7 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.body).to include(contact.name)
|
||||
end
|
||||
end
|
||||
@@ -620,6 +624,7 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(contact.reload.name).to eq('Test Blub')
|
||||
# custom attributes are merged properly without overwriting existing ones
|
||||
expect(contact.custom_attributes).to eq({ 'test' => 'new test', 'test1' => 'test1', 'test2' => 'test2' })
|
||||
|
||||
@@ -31,6 +31,7 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(conversation.messages.count).to eq(1)
|
||||
expect(conversation.messages.first.content).to eq(params[:content])
|
||||
end
|
||||
@@ -182,6 +183,7 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:contact][:id]).to eq(conversation.contact_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,6 +27,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
body = JSON.parse(response.body, symbolize_names: true)
|
||||
expect(body[:data][:meta][:all_count]).to eq(1)
|
||||
expect(body[:data][:meta].keys).to include(:all_count, :mine_count, :assigned_count, :unassigned_count)
|
||||
@@ -165,6 +166,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
expect(response_data.count).to eq(2)
|
||||
end
|
||||
@@ -234,6 +236,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(conversation.display_id)
|
||||
end
|
||||
|
||||
@@ -282,6 +285,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high')
|
||||
end
|
||||
|
||||
@@ -342,6 +346,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
expect(response_data[:additional_attributes]).to eq(additional_attributes)
|
||||
end
|
||||
@@ -449,9 +454,11 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { status: 'open' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:payload].size).to eq(2)
|
||||
end
|
||||
|
||||
@@ -95,6 +96,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(inbox.id)
|
||||
end
|
||||
|
||||
@@ -383,6 +385,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.body).to include('test.com')
|
||||
end
|
||||
|
||||
@@ -478,6 +481,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(inbox.reload.enable_auto_assignment).to be_falsey
|
||||
expect(inbox.reload.portal_id).to eq(portal.id)
|
||||
expect(response.parsed_body['name']).to eq 'new test inbox'
|
||||
|
||||
@@ -22,6 +22,7 @@ RSpec.describe 'Teams API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.parsed_body.first['id']).to eq(account.teams.first.id)
|
||||
end
|
||||
end
|
||||
@@ -45,6 +46,7 @@ RSpec.describe 'Teams API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.parsed_body['id']).to eq(team.id)
|
||||
end
|
||||
end
|
||||
@@ -83,6 +85,7 @@ RSpec.describe 'Teams API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(Team.count).to eq(2)
|
||||
end
|
||||
end
|
||||
@@ -121,6 +124,7 @@ RSpec.describe 'Teams API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(team.reload.name).to eq('new-team')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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!' }
|
||||
@@ -126,6 +149,7 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.body).to include(account.name)
|
||||
expect(response.body).to include(account.locale)
|
||||
expect(response.body).to include(account.domain)
|
||||
@@ -161,22 +185,22 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/{account.id}' do
|
||||
describe 'PATCH /api/v1/accounts/{account.id}' do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}"
|
||||
patch "/api/v1/accounts/#{account.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthorized user' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
@@ -196,11 +220,20 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
company_size: '1-10'
|
||||
}
|
||||
|
||||
it 'returns a valid schema' do
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to conform_schema(200)
|
||||
end
|
||||
|
||||
it 'modifies an account' do
|
||||
put "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(account.reload.name).to eq(params[:name])
|
||||
@@ -219,19 +252,19 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
|
||||
it 'updates onboarding step to invite_team if onboarding step is present in account custom attributes' do
|
||||
account.update(custom_attributes: { onboarding_step: 'account_update' })
|
||||
put "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
|
||||
end
|
||||
|
||||
it 'will not update onboarding step if onboarding step is not present in account custom attributes' do
|
||||
put "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(account.reload.custom_attributes['onboarding_step']).to be_nil
|
||||
end
|
||||
@@ -239,10 +272,10 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
it 'Throws error 422' do
|
||||
params[:name] = 'test' * 999
|
||||
|
||||
put "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
|
||||
@@ -21,6 +21,7 @@ RSpec.describe 'Profile API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['id']).to eq(agent.id)
|
||||
expect(json_response['email']).to eq(agent.email)
|
||||
@@ -50,6 +51,7 @@ RSpec.describe 'Profile API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
json_response = response.parsed_body
|
||||
agent.reload
|
||||
expect(json_response['id']).to eq(agent.id)
|
||||
@@ -64,6 +66,7 @@ RSpec.describe 'Profile API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
agent.reload
|
||||
|
||||
expect(agent.custom_attributes['phone_number']).to eq('+123456789')
|
||||
@@ -91,6 +94,7 @@ RSpec.describe 'Profile API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(agent.reload.valid_password?('Test1234!')).to be true
|
||||
end
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -144,6 +144,7 @@ RSpec.describe 'Platform Accounts API', type: :request do
|
||||
headers: { api_access_token: platform_app.access_token.token }, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to conform_schema(200)
|
||||
expect(response.body).to include(account.name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -259,10 +259,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
message_content: 'Hello assistant',
|
||||
message_history: [
|
||||
{ role: 'user', content: 'Previous message' },
|
||||
{ role: 'assistant', content: 'Previous response' }
|
||||
{ role: 'assistant', content: 'Previous response', agent_name: 'billing_scenario' }
|
||||
]
|
||||
}
|
||||
end
|
||||
let(:chat_service) { instance_double(Captain::Llm::AssistantChatService) }
|
||||
let(:agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
@@ -274,11 +276,14 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'generates a response' do
|
||||
chat_service = instance_double(Captain::Llm::AssistantChatService)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
|
||||
context 'when captain v2 is disabled' do
|
||||
it 'generates a response with the legacy assistant chat service' do
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
source: 'playground'
|
||||
).and_return(chat_service)
|
||||
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
|
||||
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: valid_params,
|
||||
@@ -292,14 +297,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
)
|
||||
expect(json_response[:content]).to eq('Assistant response')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message_history is not provided' do
|
||||
it 'uses empty array as default' do
|
||||
params_without_history = { message_content: 'Hello assistant' }
|
||||
chat_service = instance_double(Captain::Llm::AssistantChatService)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
source: 'playground'
|
||||
).and_return(chat_service)
|
||||
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
|
||||
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: params_without_history,
|
||||
@@ -313,5 +319,53 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain v2 is enabled' do
|
||||
before do
|
||||
account.enable_features('captain_integration_v2')
|
||||
end
|
||||
|
||||
it 'generates a response with the agent runner service' do
|
||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
source: 'playground'
|
||||
).and_return(agent_runner_service)
|
||||
allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' })
|
||||
expect(Captain::Llm::AssistantChatService).not_to receive(:new)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(agent_runner_service).to have_received(:generate_response).with(
|
||||
message_history: valid_params[:message_history] + [{ role: 'user', content: valid_params[:message_content] }]
|
||||
)
|
||||
expect(json_response[:response]).to eq('Assistant response')
|
||||
end
|
||||
|
||||
it 'does not duplicate the latest user message if it is already in history' do
|
||||
params_with_latest_message = {
|
||||
message_content: 'Hello assistant',
|
||||
message_history: [{ role: 'user', content: 'Hello assistant' }]
|
||||
}
|
||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
source: 'playground'
|
||||
).and_return(agent_runner_service)
|
||||
allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: params_with_latest_message,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(agent_runner_service).to have_received(:generate_response).with(
|
||||
message_history: params_with_latest_message[:message_history]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -42,6 +42,45 @@ RSpec.describe Captain::Scenario, type: :model do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#handoff_key' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'uses id plus readable slug for persisted scenarios' do
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account,
|
||||
title: 'Handle complex refund requests requiring manager approval steps')
|
||||
|
||||
expect(scenario.handoff_key).to start_with("scenario_#{scenario.id}_")
|
||||
expect(scenario.handoff_key).to end_with('_agent')
|
||||
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
|
||||
end
|
||||
|
||||
it 'uses a truncated slug key for unsaved scenarios' do
|
||||
scenario = build(:captain_scenario, assistant: assistant, account: account,
|
||||
title: 'Troubleshoot payment gateway errors for recurring subscription charges')
|
||||
|
||||
expect(scenario.handoff_key).to match(/\Ascenario_draft_[a-z0-9_]+_agent\z/)
|
||||
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
|
||||
end
|
||||
|
||||
it 'stays within length budget even for large ids' do
|
||||
scenario = build(:captain_scenario, assistant: assistant, account: account,
|
||||
title: 'A very long scenario title used only for budget verification')
|
||||
allow(scenario).to receive(:id).and_return(1_234_567_890_123_456_789)
|
||||
|
||||
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
|
||||
end
|
||||
|
||||
it 'exposes handoff keys in assistant prompt context' do
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account)
|
||||
|
||||
prompt_context = assistant.send(:prompt_context)
|
||||
scenario_config = prompt_context[:scenarios].find { |entry| entry[:title] == scenario.title }
|
||||
|
||||
expect(scenario_config[:key]).to eq(scenario.handoff_key)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'tool validation and population' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
+22
-22
@@ -24,7 +24,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'schedules captain response job for incoming messages on pending conversations' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,7 +43,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'schedules captain response job outside business hours (Captain always responds when configured)' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
|
||||
it 'performs captain handoff when quota is exceeded (OOO template will kick in after handoff)' do
|
||||
@@ -52,7 +52,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
|
||||
)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
@@ -62,7 +62,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
end
|
||||
@@ -76,7 +76,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'schedules captain response job regardless of time' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -95,7 +95,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
end
|
||||
|
||||
it 'performs handoff within business hours when quota exceeded' do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
@@ -110,7 +110,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'does not schedule captain response job' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -122,7 +122,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'does not schedule captain response job' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -130,7 +130,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'does not schedule captain response job' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :outgoing)
|
||||
create(:message, conversation: conversation, message_type: :outgoing, account: account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -144,7 +144,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.not_to(change { conversation.reload.messages.template.count })
|
||||
end
|
||||
|
||||
@@ -160,7 +160,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.not_to(change { conversation.reload.messages.template.count })
|
||||
end
|
||||
end
|
||||
@@ -174,7 +174,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.to change { conversation.reload.messages.template.count }.by(1)
|
||||
|
||||
greeting_message = conversation.reload.messages.template.last
|
||||
@@ -193,7 +193,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.to change { conversation.reload.messages.template.count }.by(1)
|
||||
|
||||
out_of_office_message = conversation.reload.messages.template.last
|
||||
@@ -211,7 +211,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.to change { conversation.reload.messages.template.count }.by(1)
|
||||
|
||||
greeting_message = conversation.reload.messages.template.last
|
||||
@@ -230,7 +230,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.to change { conversation.reload.messages.template.count }.by(1)
|
||||
|
||||
out_of_office_message = conversation.reload.messages.template.last
|
||||
@@ -245,7 +245,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
it 'schedules captain response job for incoming messages on pending campaign conversations' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(campaign_conversation, assistant)
|
||||
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming)
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
|
||||
it 'does not send greeting template on campaign conversations' do
|
||||
@@ -255,7 +255,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
|
||||
allow(greeting_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming)
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
end
|
||||
@@ -271,7 +271,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming)
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
end
|
||||
@@ -284,7 +284,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
|
||||
allow(email_collect_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming)
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
|
||||
end
|
||||
@@ -304,7 +304,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming)
|
||||
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
|
||||
end.not_to(change { campaign_conversation.messages.template.count })
|
||||
end
|
||||
end
|
||||
@@ -332,7 +332,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
|
||||
it 'sends out of office message after handoff due to quota exceeded' do
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.to change { conversation.messages.template.count }.by(1)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
@@ -356,7 +356,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
|
||||
it 'does not send out of office message after handoff' do
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.not_to(change { conversation.messages.template.count })
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
|
||||
@@ -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
|
||||
@@ -280,7 +280,10 @@ RSpec.describe Captain::BaseTaskService do
|
||||
|
||||
describe '#prompt_from_file' do
|
||||
it 'reads prompt from file' do
|
||||
allow(Rails.root).to receive(:join).and_return(instance_double(Pathname, read: 'Test prompt content'))
|
||||
allow(Rails.root).to receive(:join).and_call_original
|
||||
allow(Rails.root).to receive(:join)
|
||||
.with('lib/integrations/openai/openai_prompts', 'test.liquid')
|
||||
.and_return(instance_double(Pathname, read: 'Test prompt content'))
|
||||
expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -75,6 +75,10 @@ RSpec.configure do |config|
|
||||
config.include ActiveSupport::Testing::TimeHelpers
|
||||
config.include ActionCable::TestHelper
|
||||
config.include ActiveJob::TestHelper
|
||||
|
||||
# OpenAPI response validation via Skooma
|
||||
path_to_openapi = Rails.root.join('swagger/swagger.json')
|
||||
config.include Skooma::RSpec[path_to_openapi], type: :request
|
||||
end
|
||||
|
||||
Shoulda::Matchers.configure do |config|
|
||||
|
||||
@@ -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
|
||||
@@ -50,6 +50,26 @@ describe ActionService do
|
||||
action_service.assign_agent(['nil'])
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
context 'when agent is confirmed' do
|
||||
it 'assigns the agent to the conversation' do
|
||||
inbox_member
|
||||
action_service.assign_agent([agent.id])
|
||||
expect(conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent is unconfirmed' do
|
||||
let(:unconfirmed_agent) { create(:user, account: account, role: :agent, skip_confirmation: false) }
|
||||
let(:unconfirmed_inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: unconfirmed_agent) }
|
||||
|
||||
it 'does not assign unconfirmed agent to the conversation' do
|
||||
unconfirmed_inbox_member
|
||||
original_assignee = conversation.assignee
|
||||
action_service.assign_agent([unconfirmed_agent.id])
|
||||
expect(conversation.reload.assignee).to eq(original_assignee)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#assign_team' do
|
||||
|
||||
@@ -15,7 +15,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new)
|
||||
|
||||
# described class gets called in message after commit
|
||||
create(:message, conversation: conversation, message_type: 'activity', content: 'Conversation marked resolved!!')
|
||||
create(:message, conversation: conversation, account: conversation.account, message_type: 'activity', content: 'Conversation marked resolved!!')
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
|
||||
@@ -36,7 +36,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
expect(MessageTemplates::Template::EmailCollect).to have_received(:new).with(conversation: message.conversation)
|
||||
@@ -54,7 +54,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
|
||||
allow(greeting_service).to receive(:perform).and_return(true)
|
||||
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new).with(conversation: message.conversation)
|
||||
end
|
||||
end
|
||||
@@ -75,7 +75,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(greeting_service).to receive(:perform).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).to have_received(:new).with(conversation: message.conversation)
|
||||
expect(greeting_service).to have_received(:perform)
|
||||
@@ -90,7 +90,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new).with(conversation: message.conversation)
|
||||
end
|
||||
@@ -105,7 +105,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new).with(conversation: message.conversation)
|
||||
end
|
||||
@@ -123,7 +123,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
|
||||
allow(greeting_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
end
|
||||
@@ -139,7 +139,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
end
|
||||
@@ -151,7 +151,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
conversation = create(:conversation, contact: contact)
|
||||
conversation.inbox.update(greeting_enabled: true, enable_email_collect: true, greeting_message: 'Hi, this is a greeting message')
|
||||
|
||||
message = create(:message, conversation: conversation, content_type: :incoming_email)
|
||||
message = create(:message, conversation: conversation, account: conversation.account, content_type: :incoming_email)
|
||||
message.content_attributes = { email: { auto_reply: true } }
|
||||
message.save!
|
||||
|
||||
@@ -188,7 +188,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: message.conversation)
|
||||
expect(out_of_office_service).to have_received(:perform)
|
||||
@@ -202,13 +202,13 @@ describe MessageTemplates::HookExecutionService do
|
||||
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
|
||||
conversation.inbox.working_hours.today.update!(closed_all_day: true)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :outgoing, created_at: 2.minutes.ago)
|
||||
create(:message, conversation: conversation, account: conversation.account, message_type: :outgoing, created_at: 2.minutes.ago)
|
||||
|
||||
out_of_office_service = double
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
expect(out_of_office_service).not_to have_received(:perform)
|
||||
@@ -221,13 +221,13 @@ describe MessageTemplates::HookExecutionService do
|
||||
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
|
||||
conversation.inbox.working_hours.today.update!(closed_all_day: true)
|
||||
|
||||
create(:message, conversation: conversation, private: true, message_type: :outgoing, created_at: 2.minutes.ago)
|
||||
create(:message, conversation: conversation, account: conversation.account, private: true, message_type: :outgoing, created_at: 2.minutes.ago)
|
||||
|
||||
out_of_office_service = double
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: conversation)
|
||||
expect(out_of_office_service).to have_received(:perform)
|
||||
@@ -247,7 +247,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation, message_type: 'outgoing')
|
||||
message = create(:message, conversation: conversation, account: conversation.account, message_type: 'outgoing')
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new).with(conversation: message.conversation)
|
||||
expect(out_of_office_service).not_to have_received(:perform)
|
||||
@@ -265,7 +265,7 @@ describe MessageTemplates::HookExecutionService do
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(false)
|
||||
|
||||
message = create(:message, conversation: conversation)
|
||||
message = create(:message, conversation: conversation, account: conversation.account)
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new).with(conversation: message.conversation)
|
||||
expect(out_of_office_service).not_to receive(:perform)
|
||||
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
|
||||
@@ -0,0 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'OpenAPI document', type: :request do
|
||||
it 'is valid against the OpenAPI 3.1.0 meta-schema' do
|
||||
expect(skooma_openapi_schema).to be_valid_document
|
||||
end
|
||||
end
|
||||
@@ -70,6 +70,8 @@ platform_account:
|
||||
$ref: ./resource/platform_account.yml
|
||||
team:
|
||||
$ref: ./resource/team.yml
|
||||
label:
|
||||
$ref: ./resource/label.yml
|
||||
integrations_app:
|
||||
$ref: ./resource/integrations/app.yml
|
||||
integrations_hook:
|
||||
@@ -142,6 +144,10 @@ inbox_update_payload:
|
||||
team_create_update_payload:
|
||||
$ref: ./request/team/create_update_payload.yml
|
||||
|
||||
# Label
|
||||
label_create_update_payload:
|
||||
$ref: ./request/label/create_update_payload.yml
|
||||
|
||||
# Custom Filter
|
||||
custom_filter_create_update_payload:
|
||||
$ref: ./request/custom_filter/create_update_payload.yml
|
||||
|
||||
@@ -18,20 +18,23 @@ properties:
|
||||
example: 'support@example.com'
|
||||
# Settings parameters (stored in settings JSONB column)
|
||||
auto_resolve_after:
|
||||
type: integer
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
minimum: 10
|
||||
maximum: 1439856
|
||||
nullable: true
|
||||
description: Auto resolve conversations after specified minutes
|
||||
example: 1440
|
||||
auto_resolve_message:
|
||||
type: string
|
||||
nullable: true
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Message to send when auto resolving
|
||||
example: "This conversation has been automatically resolved due to inactivity"
|
||||
auto_resolve_ignore_waiting:
|
||||
type: boolean
|
||||
nullable: true
|
||||
type:
|
||||
- boolean
|
||||
- 'null'
|
||||
description: Whether to ignore waiting conversations for auto resolve
|
||||
example: false
|
||||
# Custom attributes parameters (stored in custom_attributes JSONB column)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
type: object
|
||||
required:
|
||||
- source_id
|
||||
- inbox_id
|
||||
properties:
|
||||
source_id:
|
||||
type: string
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description: The label title
|
||||
example: support
|
||||
description:
|
||||
type: string
|
||||
description: A short description for the label
|
||||
example: Conversations that need support follow-up
|
||||
color:
|
||||
type: string
|
||||
description: Hex color code for the label
|
||||
example: '#1f93ff'
|
||||
show_on_sidebar:
|
||||
type: boolean
|
||||
description: Whether the label should appear in the sidebar
|
||||
example: true
|
||||
@@ -26,9 +26,7 @@ properties:
|
||||
type: object
|
||||
description: Cache keys for the account
|
||||
features:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
type: object
|
||||
description: Enabled features for the account
|
||||
settings:
|
||||
type: object
|
||||
@@ -48,16 +46,24 @@ properties:
|
||||
description: Custom attributes of the account
|
||||
properties:
|
||||
plan_name:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Subscription plan name
|
||||
subscribed_quantity:
|
||||
type: number
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: Subscribed quantity
|
||||
subscription_status:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Subscription status
|
||||
subscription_ends_on:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date
|
||||
description: Subscription end date
|
||||
industry:
|
||||
|
||||
@@ -3,7 +3,9 @@ allOf:
|
||||
- type: object
|
||||
properties:
|
||||
latest_chatwoot_version:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Latest version of Chatwoot available
|
||||
example: "3.0.0"
|
||||
subscribed_features:
|
||||
|
||||
@@ -31,5 +31,7 @@ properties:
|
||||
type: string
|
||||
description: The thumbnail of the agent
|
||||
custom_role_id:
|
||||
type: integer
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
description: The custom role id of the agent
|
||||
|
||||
@@ -38,8 +38,9 @@ properties:
|
||||
type: integer
|
||||
description: Version number of the audit log entry
|
||||
comment:
|
||||
type: string
|
||||
nullable: true
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Optional comment associated with the audit log entry
|
||||
request_uuid:
|
||||
type: string
|
||||
@@ -48,6 +49,7 @@ properties:
|
||||
type: integer
|
||||
description: Unix timestamp when the audit log entry was created
|
||||
remote_address:
|
||||
type: string
|
||||
nullable: true
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: IP address from which the action was performed
|
||||
@@ -31,9 +31,10 @@ properties:
|
||||
type: string
|
||||
description: Status of the message
|
||||
source_id:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Source ID of the message
|
||||
nullable: true
|
||||
content_type:
|
||||
type: string
|
||||
description: Type of the content
|
||||
@@ -41,13 +42,15 @@ properties:
|
||||
type: object
|
||||
description: Attributes of the content
|
||||
sender_type:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Type of the sender
|
||||
nullable: true
|
||||
sender_id:
|
||||
type: integer
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
description: ID of the sender
|
||||
nullable: true
|
||||
external_source_ids:
|
||||
type: object
|
||||
description: External source IDs
|
||||
@@ -55,9 +58,10 @@ properties:
|
||||
type: object
|
||||
description: Additional attributes of the message
|
||||
processed_message_content:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Processed message content
|
||||
nullable: true
|
||||
sentiment:
|
||||
type: object
|
||||
description: Sentiment analysis of the message
|
||||
@@ -66,9 +70,10 @@ properties:
|
||||
description: Conversation details
|
||||
properties:
|
||||
assignee_id:
|
||||
type: integer
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
description: ID of the assignee
|
||||
nullable: true
|
||||
unread_count:
|
||||
type: integer
|
||||
description: Count of unread messages
|
||||
|
||||
@@ -11,7 +11,9 @@ properties:
|
||||
type: string
|
||||
description: Country of the contact
|
||||
country_code:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Country code of the contact
|
||||
created_at_ip:
|
||||
type: string
|
||||
@@ -26,16 +28,18 @@ properties:
|
||||
type: integer
|
||||
description: The ID of the contact
|
||||
identifier:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: The identifier of the contact
|
||||
nullable: true
|
||||
name:
|
||||
type: string
|
||||
description: The name of the contact
|
||||
phone_number:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: The phone number of the contact
|
||||
nullable: true
|
||||
thumbnail:
|
||||
type: string
|
||||
description: The thumbnail of the contact
|
||||
|
||||
@@ -22,6 +22,7 @@ properties:
|
||||
type: string
|
||||
description: Type of channel
|
||||
provider:
|
||||
type: string
|
||||
description: Provider of the inbox
|
||||
nullable: true
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Provider of the inbox
|
||||
@@ -11,7 +11,9 @@ properties:
|
||||
type: string
|
||||
description: Country of the contact
|
||||
country_code:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Country code of the contact
|
||||
created_at_ip:
|
||||
type: string
|
||||
@@ -21,9 +23,10 @@ properties:
|
||||
description: Availability status of the contact
|
||||
enum: ["online", "offline"]
|
||||
email:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: The email address of the contact
|
||||
nullable: true
|
||||
id:
|
||||
type: integer
|
||||
description: The ID of the contact
|
||||
@@ -31,16 +34,18 @@ properties:
|
||||
type: string
|
||||
description: The name of the contact
|
||||
phone_number:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: The phone number of the contact
|
||||
nullable: true
|
||||
blocked:
|
||||
type: boolean
|
||||
description: Whether the contact is blocked
|
||||
identifier:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: The identifier of the contact
|
||||
nullable: true
|
||||
thumbnail:
|
||||
type: string
|
||||
description: The thumbnail of the contact
|
||||
@@ -48,9 +53,10 @@ properties:
|
||||
type: object
|
||||
description: The custom attributes of the contact
|
||||
last_activity_at:
|
||||
type: integer
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
description: Timestamp of last activity
|
||||
nullable: true
|
||||
created_at:
|
||||
type: integer
|
||||
description: Timestamp when contact was created
|
||||
|
||||
@@ -4,5 +4,7 @@ properties:
|
||||
type: integer
|
||||
description: Total number of contacts
|
||||
current_page:
|
||||
type: string
|
||||
description: Current page number
|
||||
type:
|
||||
- string
|
||||
- integer
|
||||
description: Current page number
|
||||
@@ -43,7 +43,9 @@ properties:
|
||||
type: boolean
|
||||
description: Whether the conversation is muted
|
||||
snoozed_until:
|
||||
type: number
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: The time at which the conversation will be unmuted
|
||||
status:
|
||||
type: string
|
||||
@@ -56,29 +58,38 @@ properties:
|
||||
type: number
|
||||
description: The time at which conversation was updated
|
||||
timestamp:
|
||||
type: string
|
||||
type: number
|
||||
description: The time at which conversation was created
|
||||
first_reply_created_at:
|
||||
type: number
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: The time at which the first reply was created
|
||||
unread_count:
|
||||
type: number
|
||||
description: The number of unread messages
|
||||
last_non_activity_message:
|
||||
type: object
|
||||
$ref: '#/components/schemas/message'
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/message'
|
||||
- type: 'null'
|
||||
description: The last non activity message
|
||||
last_activity_at:
|
||||
type: number
|
||||
description: The last activity at of the conversation
|
||||
priority:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: The priority of the conversation
|
||||
waiting_since:
|
||||
type: number
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: The time at which the conversation was waiting
|
||||
sla_policy_id:
|
||||
type: number
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: The ID of the SLA policy
|
||||
applied_sla:
|
||||
type: object
|
||||
|
||||
@@ -45,11 +45,18 @@ properties:
|
||||
contact:
|
||||
$ref: '#/components/schemas/contact_detail'
|
||||
description: Contact details
|
||||
agent_last_seen_at:
|
||||
type: string
|
||||
description: Timestamp when the agent last saw the conversation
|
||||
assignee:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/agent'
|
||||
description: The agent assigned to the conversation
|
||||
nullable: true
|
||||
agent_last_seen_at:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Timestamp when the agent last saw the conversation
|
||||
assignee_last_seen_at:
|
||||
type: string
|
||||
description: Timestamp when the assignee last saw the conversation
|
||||
nullable: true
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Timestamp when the assignee last saw the conversation
|
||||
@@ -13,7 +13,9 @@ properties:
|
||||
type: string
|
||||
description: The availability status of the sender
|
||||
email:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: The email of the sender
|
||||
id:
|
||||
type: number
|
||||
@@ -22,16 +24,22 @@ properties:
|
||||
type: string
|
||||
description: The name of the sender
|
||||
phone_number:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: The phone number of the sender
|
||||
blocked:
|
||||
type: boolean
|
||||
description: Whether the sender is blocked
|
||||
identifier:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: The identifier of the sender
|
||||
thumbnail:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Avatar URL of the contact
|
||||
custom_attributes:
|
||||
type: object
|
||||
|
||||
@@ -28,16 +28,22 @@ properties:
|
||||
type: string
|
||||
description: Script used to load the website widget
|
||||
welcome_title:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Welcome title to be displayed on the widget
|
||||
welcome_tagline:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Welcome tagline to be displayed on the widget
|
||||
greeting_enabled:
|
||||
type: boolean
|
||||
description: The flag which shows whether greeting is enabled
|
||||
greeting_message:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: A greeting message when the user starts the conversation
|
||||
channel_id:
|
||||
type: number
|
||||
@@ -55,7 +61,9 @@ properties:
|
||||
type: object
|
||||
description: Configuration settings for auto assignment
|
||||
out_of_office_message:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Message to show when agents are out of office
|
||||
working_hours:
|
||||
type: array
|
||||
@@ -70,16 +78,24 @@ properties:
|
||||
type: boolean
|
||||
description: Whether the inbox is closed for the entire day
|
||||
open_hour:
|
||||
type: number
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: Hour when inbox opens (0-23)
|
||||
open_minutes:
|
||||
type: number
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: Minutes of the hour when inbox opens (0-59)
|
||||
close_hour:
|
||||
type: number
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: Hour when inbox closes (0-23)
|
||||
close_minutes:
|
||||
type: number
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: Minutes of the hour when inbox closes (0-59)
|
||||
open_all_day:
|
||||
type: boolean
|
||||
@@ -88,7 +104,9 @@ properties:
|
||||
type: string
|
||||
description: Timezone configuration for the inbox
|
||||
callback_webhook_url:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Webhook URL for callbacks
|
||||
allow_messages_after_resolved:
|
||||
type: boolean
|
||||
@@ -100,26 +118,38 @@ properties:
|
||||
type: string
|
||||
description: Type of sender name to display (e.g., friendly)
|
||||
business_name:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Business name associated with the inbox
|
||||
hmac_mandatory:
|
||||
type: boolean
|
||||
description: Whether HMAC verification is mandatory
|
||||
selected_feature_flags:
|
||||
type: object
|
||||
type:
|
||||
- array
|
||||
- 'null'
|
||||
description: Selected feature flags for the inbox
|
||||
items:
|
||||
type: string
|
||||
reply_time:
|
||||
type: string
|
||||
description: Expected reply time
|
||||
messaging_service_sid:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Messaging service SID for SMS providers
|
||||
phone_number:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Phone number associated with the inbox
|
||||
medium:
|
||||
type: string
|
||||
description: Medium of communication (e.g., sms, email)
|
||||
provider:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Provider of the channel
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
description: The ID of the label
|
||||
title:
|
||||
type: string
|
||||
description: The title of the label
|
||||
description:
|
||||
type: string
|
||||
description: The description of the label
|
||||
color:
|
||||
type: string
|
||||
description: Hex color code for the label
|
||||
show_on_sidebar:
|
||||
type: boolean
|
||||
description: Whether the label should appear in the sidebar
|
||||
@@ -17,37 +17,49 @@ properties:
|
||||
description: The ID of the conversation
|
||||
message_type:
|
||||
type: integer
|
||||
enum: [0, 1, 2]
|
||||
enum: [0, 1, 2, 3]
|
||||
description: The type of the message
|
||||
created_at:
|
||||
type: integer
|
||||
description: The time at which message was created
|
||||
updated_at:
|
||||
type: integer
|
||||
type:
|
||||
- integer
|
||||
- string
|
||||
description: The time at which message was updated
|
||||
private:
|
||||
type: boolean
|
||||
description: The flags which shows whether the message is private or not
|
||||
status:
|
||||
type: string
|
||||
enum: ["sent", "delivered", "read", "failed"]
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
enum: ["sent", "delivered", "read", "failed", null]
|
||||
description: The status of the message
|
||||
source_id:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: The source ID of the message
|
||||
content_type:
|
||||
type: string
|
||||
enum: ["text", "input_select", "cards", "form"]
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
enum: ["text", "input_text", "input_textarea", "input_email", "input_select", "cards", "form", "article", "incoming_email", "input_csat", "integrations", "sticker", "voice_call", null]
|
||||
description: The type of the template message
|
||||
content_attributes:
|
||||
type: object
|
||||
description: The content attributes for each content_type
|
||||
sender_type:
|
||||
type: string
|
||||
enum: ["contact", "agent", "agent_bot"]
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
enum: ["Contact", "User", "AgentBot", "Captain::Assistant", null]
|
||||
description: The type of the sender
|
||||
sender_id:
|
||||
type: number
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: The ID of the sender
|
||||
external_source_ids:
|
||||
type: object
|
||||
@@ -56,16 +68,24 @@ properties:
|
||||
type: object
|
||||
description: The additional attributes of the message
|
||||
processed_message_content:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: The processed message content
|
||||
sentiment:
|
||||
type: object
|
||||
type:
|
||||
- object
|
||||
- 'null'
|
||||
description: The sentiment of the message
|
||||
conversation:
|
||||
type: object
|
||||
type:
|
||||
- object
|
||||
- 'null'
|
||||
description: The conversation object
|
||||
attachment:
|
||||
type: object
|
||||
type:
|
||||
- object
|
||||
- 'null'
|
||||
description: The file object attached to the image
|
||||
sender:
|
||||
type: object
|
||||
|
||||
@@ -18,7 +18,7 @@ properties:
|
||||
description: "The type of the message (0: incoming, 1: outgoing, 2: activity, 3: template)"
|
||||
content_type:
|
||||
type: string
|
||||
enum: ["text", "input_select", "cards", "form", "input_csat"]
|
||||
enum: ["text", "input_text", "input_textarea", "input_email", "input_select", "cards", "form", "article", "incoming_email", "input_csat", "integrations", "sticker", "voice_call"]
|
||||
description: The type of the message content
|
||||
status:
|
||||
type: string
|
||||
@@ -29,9 +29,15 @@ properties:
|
||||
description: The content attributes for each content_type
|
||||
properties:
|
||||
in_reply_to:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: ID of the message this is replying to
|
||||
nullable: true
|
||||
echo_id:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: The echo ID of the message, used for deduplication
|
||||
created_at:
|
||||
type: integer
|
||||
description: The timestamp when message was created
|
||||
@@ -39,9 +45,38 @@ properties:
|
||||
type: boolean
|
||||
description: The flag which shows whether the message is private or not
|
||||
source_id:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: The source ID of the message
|
||||
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
|
||||
|
||||
@@ -4,16 +4,19 @@ properties:
|
||||
type: integer
|
||||
description: Total number of articles
|
||||
archived_articles_count:
|
||||
type: integer
|
||||
nullable: true
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
description: Number of archived articles
|
||||
published_count:
|
||||
type: integer
|
||||
nullable: true
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
description: Number of published articles
|
||||
draft_articles_count:
|
||||
type: integer
|
||||
nullable: true
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
description: Number of draft articles
|
||||
categories_count:
|
||||
type: integer
|
||||
|
||||
@@ -26,16 +26,19 @@ properties:
|
||||
type: number
|
||||
description: ID of the account
|
||||
conversation_id:
|
||||
type: number
|
||||
nullable: true
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: ID of the conversation
|
||||
inbox_id:
|
||||
type: number
|
||||
nullable: true
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: ID of the inbox
|
||||
user_id:
|
||||
type: number
|
||||
nullable: true
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: ID of the user/agent
|
||||
created_at:
|
||||
type: string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user