Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aca28ce8ca | ||
|
|
a9123e7d66 | ||
|
|
9967101b48 | ||
|
|
654fcd43f2 | ||
|
|
284977687c | ||
|
|
18dc77aa56 |
@@ -1,3 +1,4 @@
|
||||
---
|
||||
ignore:
|
||||
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
||||
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
module TimezoneHelper
|
||||
def timezone_name_from_params(timezone, offset)
|
||||
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
|
||||
|
||||
timezone_name_from_offset(offset)
|
||||
end
|
||||
|
||||
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
|
||||
# would return the timezone without considering day light savings. To get the correct timezone,
|
||||
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
|
||||
|
||||
@@ -8,9 +8,11 @@ import bulkActions from './bulkActions.json';
|
||||
import campaign from './campaign.json';
|
||||
import cannedMgmt from './cannedMgmt.json';
|
||||
import chatlist from './chatlist.json';
|
||||
import companies from './companies.json';
|
||||
import components from './components.json';
|
||||
import contact from './contact.json';
|
||||
import contactFilters from './contactFilters.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import conversation from './conversation.json';
|
||||
import csatMgmt from './csatMgmt.json';
|
||||
import customRole from './customRole.json';
|
||||
@@ -26,6 +28,7 @@ import integrations from './integrations.json';
|
||||
import labelsMgmt from './labelsMgmt.json';
|
||||
import login from './login.json';
|
||||
import macros from './macros.json';
|
||||
import mfa from './mfa.json';
|
||||
import report from './report.json';
|
||||
import resetPassword from './resetPassword.json';
|
||||
import search from './search.json';
|
||||
@@ -33,8 +36,11 @@ import setNewPassword from './setNewPassword.json';
|
||||
import settings from './settings.json';
|
||||
import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import snooze from './snooze.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import webhooks from './webhooks.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import yearInReview from './yearInReview.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
@@ -47,9 +53,11 @@ export default {
|
||||
...campaign,
|
||||
...cannedMgmt,
|
||||
...chatlist,
|
||||
...companies,
|
||||
...components,
|
||||
...contact,
|
||||
...contactFilters,
|
||||
...contentTemplates,
|
||||
...conversation,
|
||||
...csatMgmt,
|
||||
...customRole,
|
||||
@@ -65,6 +73,7 @@ export default {
|
||||
...labelsMgmt,
|
||||
...login,
|
||||
...macros,
|
||||
...mfa,
|
||||
...report,
|
||||
...resetPassword,
|
||||
...search,
|
||||
@@ -72,6 +81,9 @@ export default {
|
||||
...settings,
|
||||
...signup,
|
||||
...sla,
|
||||
...snooze,
|
||||
...teamsSettings,
|
||||
...webhooks,
|
||||
...whatsappTemplates,
|
||||
...yearInReview,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
create_bot_resolved_event(conversation, reporting_event)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def first_reply_created(event)
|
||||
@@ -42,6 +43,7 @@ class ReportingEventListener < BaseListener
|
||||
)
|
||||
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def reply_created(event)
|
||||
@@ -66,13 +68,16 @@ class ReportingEventListener < BaseListener
|
||||
event_end_time: message.created_at
|
||||
)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_bot_handoff(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
event_end_time = event.timestamp
|
||||
|
||||
# check if a conversation_bot_handoff event exists for this conversation
|
||||
# Best-effort guard: raw report reads count bot handoffs with DISTINCT conversation_id,
|
||||
# while rollup counts assume one conversation_bot_handoff event per conversation.
|
||||
# That uniqueness is not currently enforced at the database level.
|
||||
bot_handoff_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
|
||||
return if bot_handoff_event.present?
|
||||
|
||||
@@ -90,6 +95,7 @@ class ReportingEventListener < BaseListener
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_captain_inference_resolved(event)
|
||||
@@ -166,5 +172,16 @@ class ReportingEventListener < BaseListener
|
||||
bot_resolved_event = reporting_event.dup
|
||||
bot_resolved_event.name = 'conversation_bot_resolved'
|
||||
bot_resolved_event.save!
|
||||
safe_rollup(bot_resolved_event)
|
||||
end
|
||||
|
||||
def safe_rollup(reporting_event)
|
||||
# Rollups are derived from the raw reporting event. If a transient rollup write
|
||||
# failure bubbles out here, Sidekiq retries the dispatcher job and can insert the
|
||||
# same raw event again. That can temporarily under-report rollups, but the source
|
||||
# event is preserved and rollup data can be rebuilt or re-applied later.
|
||||
ReportingEvents::RollupService.perform(reporting_event)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: reporting_event.account).capture_exception
|
||||
end
|
||||
end
|
||||
|
||||
@@ -85,11 +85,13 @@ 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_auto_resolve_mode
|
||||
include AccountCaptainAutoResolve
|
||||
@@ -215,6 +217,12 @@ 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}")
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# == 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
|
||||
@@ -192,6 +192,25 @@ class User < ApplicationRecord
|
||||
Chatwoot.mfa_enabled?
|
||||
end
|
||||
|
||||
# Workaround for Devise 4.9.x race condition vulnerability (GHSA-57hq-95w6-v4fc).
|
||||
#
|
||||
# The Confirmable module's reconfirmable flow has a race condition where concurrent
|
||||
# email change requests can desynchronize confirmation tokens, allowing an attacker
|
||||
# to confirm an email they don't own. Fixed in Devise 5.0.3 by persisting
|
||||
# unconfirmed_email before regenerating the confirmation token.
|
||||
#
|
||||
# We can't upgrade to Devise 5.0.3 because devise-two-factor only added Devise 5
|
||||
# support in v6.4.0, which simultaneously raised its Rails minimum to 7.2+.
|
||||
# No released version supports both Devise 5 and Rails 7.1.
|
||||
#
|
||||
# This override applies the same fix locally: force-mark unconfirmed_email
|
||||
# as dirty before assignment so ActiveRecord always writes it, keeping it
|
||||
# in sync with the regenerated confirmation token. Remove once on Devise 5+.
|
||||
def postpone_email_change_until_confirmation_and_regenerate_confirmation_token
|
||||
unconfirmed_email_will_change!
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_macros
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ReportingEvents::BackfillService
|
||||
DIMENSIONS = [
|
||||
{ type: 'account', group_column: nil },
|
||||
{ type: 'agent', group_column: :user_id },
|
||||
{ type: 'inbox', group_column: :inbox_id }
|
||||
].freeze
|
||||
|
||||
# TODO: Move this to EventMetricRegistry when we expand distinct-counting support.
|
||||
# The live path already guards uniqueness in ReportingEventListener#conversation_bot_handoff,
|
||||
# but historical duplicates can exist since it's not enforced at the DB level.
|
||||
# These events are queried per-dimension (not group-then-sum) because COUNT(DISTINCT) is not additive.
|
||||
DISTINCT_COUNT_EVENTS = %w[conversation_bot_handoff].freeze
|
||||
|
||||
DISTINCT_COUNT_SQL = Arel.sql('COUNT(DISTINCT conversation_id)')
|
||||
|
||||
def self.backfill_date(account, date)
|
||||
new(account, date).perform
|
||||
end
|
||||
|
||||
def initialize(account, date)
|
||||
@account = account
|
||||
@date = date
|
||||
end
|
||||
|
||||
def perform
|
||||
start_utc, end_utc = date_boundaries_in_utc
|
||||
rollup_rows = build_rollup_rows(start_utc, end_utc)
|
||||
|
||||
ReportingEventsRollup.transaction do
|
||||
delete_existing_rollups
|
||||
bulk_insert_rollups(rollup_rows) if rollup_rows.any?
|
||||
end
|
||||
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 = tz.parse((@date + 1.day).to_s)
|
||||
[start_in_tz.utc, end_in_tz.utc]
|
||||
end
|
||||
|
||||
def build_rollup_rows(start_utc, end_utc)
|
||||
aggregates = build_aggregates(start_utc, end_utc)
|
||||
|
||||
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(start_utc, end_utc)
|
||||
aggregates = Hash.new { |h, k| h[k] = { count: 0, sum_value: 0.0, sum_value_business_hours: 0.0 } }
|
||||
standard_names = ReportingEvents::EventMetricRegistry.event_names - DISTINCT_COUNT_EVENTS
|
||||
base = @account.reporting_events.where(created_at: start_utc...end_utc)
|
||||
|
||||
DIMENSIONS.each do |dimension|
|
||||
aggregate_standard_events(aggregates, base.where(name: standard_names), dimension)
|
||||
aggregate_distinct_events(aggregates, base.where(name: DISTINCT_COUNT_EVENTS), dimension)
|
||||
end
|
||||
|
||||
aggregates
|
||||
end
|
||||
|
||||
def aggregate_standard_events(aggregates, scope, dimension)
|
||||
group_cols, selects = dimension_groups_and_selects(dimension)
|
||||
|
||||
scope.group(*group_cols).pluck(*selects).each do |row|
|
||||
event_name, dimension_id, count, sum_value, sum_value_business_hours = unpack_row(row, dimension)
|
||||
next if dimension_id.nil?
|
||||
|
||||
accumulate_metrics(aggregates, dimension[:type], dimension_id, event_name,
|
||||
{ count: count, sum_value: sum_value, sum_value_business_hours: sum_value_business_hours })
|
||||
end
|
||||
end
|
||||
|
||||
def accumulate_metrics(aggregates, dimension_type, dimension_id, event_name, values)
|
||||
ReportingEvents::EventMetricRegistry.metrics_for_aggregate(event_name, **values).each do |metric, metric_data|
|
||||
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
|
||||
|
||||
def aggregate_distinct_events(aggregates, scope, dimension)
|
||||
return if DISTINCT_COUNT_EVENTS.empty?
|
||||
|
||||
group_cols = dimension[:group_column] ? [:name, dimension[:group_column]] : [:name]
|
||||
|
||||
scope.group(*group_cols).pluck(*group_cols, DISTINCT_COUNT_SQL).each do |row|
|
||||
event_name, dimension_id, count = dimension[:group_column] ? row : [row[0], @account.id, row[1]]
|
||||
next if dimension_id.nil?
|
||||
|
||||
accumulate_metrics(aggregates, dimension[:type], dimension_id, event_name,
|
||||
{ count: count, sum_value: 0, sum_value_business_hours: 0 })
|
||||
end
|
||||
end
|
||||
|
||||
def dimension_groups_and_selects(dimension)
|
||||
agg_selects = [Arel.sql('COUNT(*)'), Arel.sql('COALESCE(SUM(value), 0)'), Arel.sql('COALESCE(SUM(value_in_business_hours), 0)')]
|
||||
|
||||
if dimension[:group_column]
|
||||
[[:name, dimension[:group_column]], [:name, dimension[:group_column], *agg_selects]]
|
||||
else
|
||||
[[:name], [:name, *agg_selects]]
|
||||
end
|
||||
end
|
||||
|
||||
def unpack_row(row, dimension)
|
||||
if dimension[:group_column]
|
||||
# [name, dimension_id, count, sum_value, sum_value_business_hours]
|
||||
row
|
||||
else
|
||||
# [name, count, sum_value, sum_value_business_hours] → inject account id
|
||||
[row[0], @account.id, row[1], row[2], row[3]]
|
||||
end
|
||||
end
|
||||
|
||||
def bulk_insert_rollups(rollup_rows)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
ReportingEventsRollup.insert_all(rollup_rows)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
module ReportingEvents::EventMetricRegistry
|
||||
# Describes one rollup metric emitted by a raw reporting event.
|
||||
# rollup_metric: metric name stored in reporting_events_rollups.
|
||||
# payload_kind: whether the emitted row carries only a count or a duration payload.
|
||||
Metric = Data.define(:rollup_metric, :payload_kind)
|
||||
|
||||
EVENTS = {
|
||||
conversation_resolved: [
|
||||
Metric.new(rollup_metric: :resolutions_count, payload_kind: :count),
|
||||
Metric.new(rollup_metric: :resolution_time, payload_kind: :duration)
|
||||
].freeze,
|
||||
first_response: [
|
||||
Metric.new(rollup_metric: :first_response, payload_kind: :duration)
|
||||
].freeze,
|
||||
reply_time: [
|
||||
Metric.new(rollup_metric: :reply_time, payload_kind: :duration)
|
||||
].freeze,
|
||||
conversation_bot_resolved: [
|
||||
Metric.new(rollup_metric: :bot_resolutions_count, payload_kind: :count)
|
||||
].freeze,
|
||||
conversation_bot_handoff: [
|
||||
Metric.new(rollup_metric: :bot_handoffs_count, payload_kind: :count)
|
||||
].freeze
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def event_names
|
||||
EVENTS.keys.map(&:to_s)
|
||||
end
|
||||
|
||||
def metrics_for(event)
|
||||
return {} if event.blank?
|
||||
|
||||
metrics_for_aggregate(
|
||||
event.name,
|
||||
count: 1,
|
||||
sum_value: event.try(:value),
|
||||
sum_value_business_hours: event.try(:value_in_business_hours)
|
||||
)
|
||||
end
|
||||
|
||||
def metrics_for_aggregate(event_name, count:, sum_value:, sum_value_business_hours:)
|
||||
return {} if event_name.blank?
|
||||
|
||||
values = {
|
||||
count: count.to_i,
|
||||
sum_value: sum_value.to_f,
|
||||
sum_value_business_hours: sum_value_business_hours.to_f
|
||||
}
|
||||
|
||||
EVENTS.fetch(event_name.to_sym, []).to_h do |metric|
|
||||
[metric.rollup_metric, metric_values(metric.payload_kind, values)]
|
||||
end
|
||||
end
|
||||
|
||||
private_class_method def metric_values(payload_kind, values)
|
||||
case payload_kind
|
||||
when :count
|
||||
count_values(values[:count])
|
||||
when :duration
|
||||
duration_values(values)
|
||||
else
|
||||
raise ArgumentError, "Unknown metric payload kind: #{payload_kind.inspect}"
|
||||
end
|
||||
end
|
||||
|
||||
private_class_method def count_values(count)
|
||||
{ count: count, sum_value: 0, sum_value_business_hours: 0 }
|
||||
end
|
||||
|
||||
private_class_method def duration_values(values)
|
||||
{
|
||||
count: values[:count],
|
||||
sum_value: values[:sum_value],
|
||||
sum_value_business_hours: values[:sum_value_business_hours]
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
# Raw reporting events and rollup rows do not share a single metric namespace; this registry keeps write and read paths aligned.
|
||||
# TODO: Split this into separate registries for raw event mappings and report metric definitions.
|
||||
module ReportingEvents::MetricRegistry
|
||||
# Maps report summary response keys to the metric definitions they read from.
|
||||
SUMMARY_METRICS = {
|
||||
resolutions_count: :resolved_conversations_count,
|
||||
avg_resolution_time: :avg_resolution_time,
|
||||
avg_first_response_time: :avg_first_response_time,
|
||||
reply_time: :avg_reply_time
|
||||
}.freeze
|
||||
|
||||
# Expands each raw reporting event into the rollup metric payloads persisted for aggregation.
|
||||
EVENT_METRICS = {
|
||||
'conversation_resolved' => lambda do |values|
|
||||
{
|
||||
resolutions_count: count_metric(values[:count]),
|
||||
resolution_time: duration_metric(values)
|
||||
}
|
||||
end,
|
||||
'first_response' => ->(values) { { first_response: duration_metric(values) } },
|
||||
'reply_time' => ->(values) { { reply_time: duration_metric(values) } },
|
||||
'conversation_bot_resolved' => ->(values) { { bot_resolutions_count: count_metric(values[:count]) } },
|
||||
'conversation_bot_handoff' => ->(values) { { bot_handoffs_count: count_metric(values[:count]) } }
|
||||
}.freeze
|
||||
|
||||
# Describes which report metrics are supported and how each one is sourced and aggregated.
|
||||
REPORT_METRICS = {
|
||||
conversations_count: { aggregate: :count }.freeze,
|
||||
incoming_messages_count: { aggregate: :count }.freeze,
|
||||
outgoing_messages_count: { aggregate: :count }.freeze,
|
||||
avg_first_response_time: { raw_event_name: :first_response, rollup_metric: :first_response, aggregate: :average }.freeze,
|
||||
avg_resolution_time: { raw_event_name: :conversation_resolved, rollup_metric: :resolution_time, aggregate: :average }.freeze,
|
||||
reply_time: { raw_event_name: :reply_time, rollup_metric: :reply_time, aggregate: :average }.freeze,
|
||||
resolutions_count: { raw_event_name: :conversation_resolved, rollup_metric: :resolutions_count, aggregate: :count }.freeze,
|
||||
bot_resolutions_count: { raw_event_name: :conversation_bot_resolved, rollup_metric: :bot_resolutions_count, aggregate: :count }.freeze,
|
||||
bot_handoffs_count: { raw_event_name: :conversation_bot_handoff, rollup_metric: :bot_handoffs_count, aggregate: :count,
|
||||
raw_count_strategy: :distinct_conversation }.freeze
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def event_metrics_for(event)
|
||||
return {} if event.blank?
|
||||
return {} unless EVENT_METRICS.key?(event.name.to_s)
|
||||
|
||||
event_metrics_for_aggregate(
|
||||
event.name,
|
||||
count: 1,
|
||||
sum_value: event.try(:value),
|
||||
sum_value_business_hours: event.try(:value_in_business_hours)
|
||||
)
|
||||
end
|
||||
|
||||
def event_metrics_for_aggregate(event_name, count:, sum_value:, sum_value_business_hours:)
|
||||
values = {
|
||||
count: count.to_i,
|
||||
sum_value: sum_value.to_f,
|
||||
sum_value_business_hours: sum_value_business_hours.to_f
|
||||
}
|
||||
|
||||
EVENT_METRICS[event_name.to_s]&.call(values) || {}
|
||||
end
|
||||
|
||||
def report_metric(metric)
|
||||
return if metric.blank?
|
||||
|
||||
REPORT_METRICS[metric.to_sym]
|
||||
end
|
||||
|
||||
def supported_metric?(metric)
|
||||
report_metric(metric).present?
|
||||
end
|
||||
|
||||
def aggregate_for(metric)
|
||||
report_metric(metric)&.dig(:aggregate)
|
||||
end
|
||||
|
||||
def rollup_supported_metric?(metric)
|
||||
rollup_metric_for(metric).present?
|
||||
end
|
||||
|
||||
def rollup_metric_for(metric)
|
||||
report_metric(metric)&.dig(:rollup_metric)
|
||||
end
|
||||
|
||||
def raw_event_name_for(metric)
|
||||
report_metric(metric)&.dig(:raw_event_name)
|
||||
end
|
||||
|
||||
def summary_metrics
|
||||
SUMMARY_METRICS.map do |metric_name, summary_key|
|
||||
report_metric(metric_name).merge(metric_name: metric_name, summary_key: summary_key)
|
||||
end
|
||||
end
|
||||
|
||||
private_class_method def count_metric(count)
|
||||
{ count: count, sum_value: 0, sum_value_business_hours: 0 }
|
||||
end
|
||||
|
||||
private_class_method def duration_metric(values)
|
||||
{
|
||||
count: values[:count],
|
||||
sum_value: values[:sum_value],
|
||||
sum_value_business_hours: values[:sum_value_business_hours]
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
class ReportingEvents::RollupService
|
||||
def self.perform(reporting_event)
|
||||
new(reporting_event).perform
|
||||
end
|
||||
|
||||
def initialize(reporting_event)
|
||||
@reporting_event = reporting_event
|
||||
@account = reporting_event.account
|
||||
end
|
||||
|
||||
def perform
|
||||
return unless rollup_enabled?
|
||||
|
||||
rows = build_rollup_rows
|
||||
return if rows.empty?
|
||||
|
||||
upsert_rollups(rows)
|
||||
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
|
||||
}
|
||||
end
|
||||
|
||||
def build_rollup_rows
|
||||
event_metrics = ReportingEvents::EventMetricRegistry.metrics_for(@reporting_event)
|
||||
|
||||
dimensions.each_with_object([]) do |(dimension_type, dimension_id), rows|
|
||||
next if dimension_id.nil?
|
||||
|
||||
event_metrics.each do |metric, metric_data|
|
||||
rows << rollup_attributes(dimension_type, dimension_id, metric, metric_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_rollups(rows)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
ReportingEventsRollup.upsert_all(
|
||||
rows,
|
||||
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
|
||||
@@ -48,6 +48,9 @@ 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:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
class CreateReportingEventsRollup < ActiveRecord::Migration[7.1]
|
||||
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'
|
||||
|
||||
add_index :reporting_events_rollups,
|
||||
[:account_id, :metric, :date],
|
||||
name: 'index_rollup_timeseries'
|
||||
|
||||
add_index :reporting_events_rollups,
|
||||
[:account_id, :dimension_type, :date],
|
||||
name: 'index_rollup_summary'
|
||||
end
|
||||
end
|
||||
@@ -1124,6 +1124,22 @@ 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
|
||||
|
||||
@@ -36,12 +36,14 @@ class Captain::Assistant < ApplicationRecord
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
||||
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name, :v2_migrated
|
||||
|
||||
validates :name, presence: true
|
||||
validates :description, presence: true
|
||||
validates :account_id, presence: true
|
||||
|
||||
before_save :mark_v2_migrated_on_v2_description_change
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
||||
@@ -85,8 +87,23 @@ class Captain::Assistant < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
def migrate_v1_instructions_if_needed!
|
||||
return if v2_migrated
|
||||
return if config['instructions'].blank?
|
||||
|
||||
update!(description: config['instructions'], v2_migrated: true)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mark_v2_migrated_on_v2_description_change
|
||||
return unless persisted?
|
||||
return unless will_save_change_to_description?
|
||||
return unless account&.feature_enabled?('captain_integration_v2')
|
||||
|
||||
self.v2_migrated = true
|
||||
end
|
||||
|
||||
def agent_name
|
||||
name.parameterize(separator: '_')
|
||||
end
|
||||
|
||||
@@ -27,6 +27,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
end
|
||||
|
||||
def generate_response(message_history: [])
|
||||
@assistant.migrate_v1_instructions_if_needed!
|
||||
message_to_process, context = run_payload(message_history)
|
||||
result = runner.run(message_to_process, context: context, max_turns: 100)
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
# 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)
|
||||
else
|
||||
prompt_enable_rollup_read_path(account)
|
||||
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. Verify parity before enabling the reporting_events_rollup read path.'
|
||||
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 prompt_enable_rollup_read_path(account)
|
||||
if account.feature_enabled?(:report_rollup)
|
||||
puts color('report_rollup is already enabled for this account.', :yellow, :bold)
|
||||
return
|
||||
end
|
||||
|
||||
print 'Enable report_rollup read path now? Only do this after parity verification. (y/N): '
|
||||
confirm = $stdin.gets.to_s.chomp.downcase
|
||||
puts ''
|
||||
return unless %w[y yes].include?(confirm)
|
||||
|
||||
account.enable_features!('report_rollup')
|
||||
puts color("Enabled report_rollup for account #{account.id}", :green, :bold)
|
||||
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
|
||||
@@ -0,0 +1,196 @@
|
||||
# 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)
|
||||
total_seconds = utc_offset_in_seconds(offset_input)
|
||||
return [] unless total_seconds
|
||||
|
||||
ActiveSupport::TimeZone.all.select { |tz| tz.utc_offset == total_seconds }
|
||||
end
|
||||
|
||||
def utc_offset_in_seconds(offset_input)
|
||||
normalized = offset_input.strip
|
||||
return unless normalized.match?(/\A[+-]?\d{1,2}(:\d{2})?\z/)
|
||||
|
||||
sign = normalized.start_with?('-') ? -1 : 1
|
||||
raw = normalized.delete_prefix('+').delete_prefix('-')
|
||||
hours_part, minutes_part = raw.split(':', 2)
|
||||
|
||||
hours = Integer(hours_part, 10)
|
||||
minutes = Integer(minutes_part || '0', 10)
|
||||
return unless minutes.between?(0, 59)
|
||||
|
||||
total_minutes = (hours * 60) + minutes
|
||||
return if total_minutes > max_utc_offset_minutes(sign)
|
||||
|
||||
sign * total_minutes * 60
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def max_utc_offset_minutes(sign)
|
||||
sign.negative? ? 12 * 60 : 14 * 60
|
||||
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
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
@@ -18,6 +18,23 @@ describe ReportingEventListener do
|
||||
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1
|
||||
end
|
||||
|
||||
context 'when rollup creation fails' do
|
||||
let(:event) { Events::Base.new('conversation.resolved', Time.zone.now, conversation: conversation) }
|
||||
let(:error) { StandardError.new('rollup failed') }
|
||||
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
|
||||
|
||||
before do
|
||||
allow(ReportingEvents::RollupService).to receive(:perform).and_raise(error)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
end
|
||||
|
||||
it 'captures the error without interrupting raw event creation' do
|
||||
expect { listener.conversation_resolved(event) }.not_to raise_error
|
||||
expect(ChatwootExceptionTracker).to have_received(:new).with(error, account: account)
|
||||
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when business hours enabled for inbox' do
|
||||
let(:created_at) { Time.zone.parse('March 20, 2022 00:00') }
|
||||
let(:updated_at) { Time.zone.parse('March 26, 2022 23:59') }
|
||||
|
||||
@@ -255,6 +255,21 @@ 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
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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
|
||||
@@ -0,0 +1,207 @@
|
||||
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_backfill_event(
|
||||
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 { described_class.backfill_date(account, date) }.not_to raise_error
|
||||
|
||||
rollup = find_rollup('account', account.id, '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
|
||||
|
||||
context 'when replacing rows fails atomically' do
|
||||
before do
|
||||
create(
|
||||
:reporting_events_rollup,
|
||||
account: account, date: date, dimension_type: 'account', dimension_id: account.id,
|
||||
metric: 'first_response', count: 7, sum_value: 700, sum_value_business_hours: 350
|
||||
)
|
||||
end
|
||||
|
||||
it 'preserves existing rollups when building replacement rows fails' do
|
||||
service = described_class.new(account, date)
|
||||
allow(service).to receive(:build_rollup_rows).and_raise(StandardError, 'boom')
|
||||
|
||||
expect { service.perform }.to raise_error(StandardError, 'boom')
|
||||
|
||||
rollup = find_rollup('account', account.id, 'first_response')
|
||||
expect(rollup.count).to eq(7)
|
||||
expect(rollup.sum_value).to eq(700)
|
||||
expect(rollup.sum_value_business_hours).to eq(350)
|
||||
end
|
||||
|
||||
it 'preserves existing rollups when bulk insert fails' do
|
||||
create_backfill_event(
|
||||
name: 'first_response', value: 100, value_in_business_hours: 50,
|
||||
user: user, inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 15)
|
||||
)
|
||||
|
||||
service = described_class.new(account, date)
|
||||
allow(service).to receive(:bulk_insert_rollups).and_raise(StandardError, 'boom')
|
||||
|
||||
expect { service.perform }.to raise_error(StandardError, 'boom')
|
||||
|
||||
rollup = find_rollup('account', account.id, 'first_response')
|
||||
expect(rollup.count).to eq(7)
|
||||
expect(rollup.sum_value).to eq(700)
|
||||
expect(rollup.sum_value_business_hours).to eq(350)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when aggregating grouped rows' do
|
||||
let(:second_user) { create(:user, account: account) }
|
||||
let(:second_inbox) { create(:inbox, account: account) }
|
||||
let(:second_conversation) { create(:conversation, account: account, inbox: second_inbox, assignee: second_user) }
|
||||
|
||||
before do
|
||||
create_backfill_event(name: 'first_response', value: 100, value_in_business_hours: 60, user: user,
|
||||
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 14))
|
||||
create_backfill_event(name: 'first_response', value: 40, value_in_business_hours: 20, user: user,
|
||||
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 15))
|
||||
create_backfill_event(name: 'conversation_resolved', value: 200, value_in_business_hours: 80, user: second_user,
|
||||
inbox: second_inbox, conversation: second_conversation, created_at: Time.utc(2026, 2, 11, 16))
|
||||
create_backfill_event(name: 'reply_time', value: 500, value_in_business_hours: 300, user: user,
|
||||
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 12, 5))
|
||||
described_class.backfill_date(account, date)
|
||||
end
|
||||
|
||||
it 'does not instantiate reporting events' do
|
||||
reporting_event_instantiations = count_reporting_event_instantiations do
|
||||
described_class.backfill_date(account, date)
|
||||
end
|
||||
|
||||
expect(reporting_event_instantiations).to eq(0)
|
||||
end
|
||||
|
||||
it 'creates the expected number of rollup rows' do
|
||||
rollups = ReportingEventsRollup.where(account_id: account.id, date: date)
|
||||
# 3 dimensions × first_response + 3 dimensions × resolutions_count + 3 dimensions × resolution_time
|
||||
expect(rollups.count).to eq(9)
|
||||
end
|
||||
|
||||
it 'aggregates first_response at the account dimension' do
|
||||
account_first_response = find_rollup('account', account.id, 'first_response')
|
||||
expect(account_first_response.count).to eq(2)
|
||||
expect(account_first_response.sum_value).to eq(140)
|
||||
expect(account_first_response.sum_value_business_hours).to eq(80)
|
||||
end
|
||||
|
||||
it 'aggregates first_response at the agent dimension' do
|
||||
agent_first_response = find_rollup('agent', user.id, 'first_response')
|
||||
expect(agent_first_response.count).to eq(2)
|
||||
expect(agent_first_response.sum_value).to eq(140)
|
||||
expect(agent_first_response.sum_value_business_hours).to eq(80)
|
||||
end
|
||||
|
||||
it 'aggregates resolution_time at the agent dimension' do
|
||||
agent_resolution_time = find_rollup('agent', second_user.id, 'resolution_time')
|
||||
expect(agent_resolution_time.count).to eq(1)
|
||||
expect(agent_resolution_time.sum_value).to eq(200)
|
||||
expect(agent_resolution_time.sum_value_business_hours).to eq(80)
|
||||
end
|
||||
|
||||
it 'aggregates first_response at the inbox dimension' do
|
||||
inbox_first_response = find_rollup('inbox', inbox.id, 'first_response')
|
||||
expect(inbox_first_response.count).to eq(2)
|
||||
expect(inbox_first_response.sum_value).to eq(140)
|
||||
expect(inbox_first_response.sum_value_business_hours).to eq(80)
|
||||
end
|
||||
|
||||
it 'aggregates resolution_time at the inbox dimension' do
|
||||
inbox_resolution_time = find_rollup('inbox', second_inbox.id, 'resolution_time')
|
||||
expect(inbox_resolution_time.count).to eq(1)
|
||||
expect(inbox_resolution_time.sum_value).to eq(200)
|
||||
expect(inbox_resolution_time.sum_value_business_hours).to eq(80)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when deduplicating distinct-count events' do
|
||||
let(:second_user) { create(:user, account: account) }
|
||||
let(:second_inbox) { create(:inbox, account: account) }
|
||||
let(:conversation_b) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||
let(:conversation_c) { create(:conversation, account: account, inbox: second_inbox, assignee: second_user) }
|
||||
|
||||
before do
|
||||
# Two events for the same conversation — should count as 1
|
||||
create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: user,
|
||||
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 14))
|
||||
create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: user,
|
||||
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 15))
|
||||
# Different conversation, same agent/inbox
|
||||
create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: user,
|
||||
inbox: inbox, conversation: conversation_b, created_at: Time.utc(2026, 2, 11, 16))
|
||||
# Different agent/inbox
|
||||
create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: second_user,
|
||||
inbox: second_inbox, conversation: conversation_c, created_at: Time.utc(2026, 2, 11, 17))
|
||||
described_class.backfill_date(account, date)
|
||||
end
|
||||
|
||||
it 'creates the expected number of rollup rows' do
|
||||
rollups = ReportingEventsRollup.where(account_id: account.id, date: date)
|
||||
expect(rollups.count).to eq(5)
|
||||
end
|
||||
|
||||
it 'counts 3 distinct conversations at the account dimension' do
|
||||
expect(find_rollup('account', account.id, 'bot_handoffs_count').count).to eq(3)
|
||||
end
|
||||
|
||||
it 'counts distinct conversations per agent' do
|
||||
expect(find_rollup('agent', user.id, 'bot_handoffs_count').count).to eq(2)
|
||||
expect(find_rollup('agent', second_user.id, 'bot_handoffs_count').count).to eq(1)
|
||||
end
|
||||
|
||||
it 'counts distinct conversations per inbox' do
|
||||
expect(find_rollup('inbox', inbox.id, 'bot_handoffs_count').count).to eq(2)
|
||||
expect(find_rollup('inbox', second_inbox.id, 'bot_handoffs_count').count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
def create_backfill_event(**attributes)
|
||||
create(
|
||||
:reporting_event,
|
||||
account: account,
|
||||
**attributes
|
||||
)
|
||||
end
|
||||
|
||||
def find_rollup(dimension_type, dimension_id, metric)
|
||||
ReportingEventsRollup.find_by!(
|
||||
account_id: account.id,
|
||||
date: date,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
metric: metric
|
||||
)
|
||||
end
|
||||
|
||||
def count_reporting_event_instantiations(&)
|
||||
instantiation_count = 0
|
||||
subscriber = lambda do |_name, _start, _finish, _id, payload|
|
||||
next unless payload[:class_name] == 'ReportingEvent'
|
||||
|
||||
instantiation_count += payload[:record_count]
|
||||
end
|
||||
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'instantiation.active_record', &)
|
||||
|
||||
instantiation_count
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ReportingEvents::EventMetricRegistry do
|
||||
describe '.event_names' do
|
||||
it 'returns the supported raw event names' do
|
||||
expect(described_class.event_names).to eq(
|
||||
%w[
|
||||
conversation_resolved
|
||||
first_response
|
||||
reply_time
|
||||
conversation_bot_resolved
|
||||
conversation_bot_handoff
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.metrics_for' do
|
||||
it 'returns the emitted rollup metrics for conversation_resolved' do
|
||||
event = instance_double(ReportingEvent, name: 'conversation_resolved', value: 120, value_in_business_hours: 45)
|
||||
|
||||
expect(described_class.metrics_for(event)).to eq(
|
||||
resolutions_count: {
|
||||
count: 1,
|
||||
sum_value: 0,
|
||||
sum_value_business_hours: 0
|
||||
},
|
||||
resolution_time: {
|
||||
count: 1,
|
||||
sum_value: 120.0,
|
||||
sum_value_business_hours: 45.0
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the emitted rollup metrics for first_response' do
|
||||
event = instance_double(ReportingEvent, name: 'first_response', value: 80, value_in_business_hours: 20)
|
||||
|
||||
expect(described_class.metrics_for(event)).to eq(
|
||||
first_response: {
|
||||
count: 1,
|
||||
sum_value: 80.0,
|
||||
sum_value_business_hours: 20.0
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the emitted rollup metrics for reply_time' do
|
||||
event = instance_double(ReportingEvent, name: 'reply_time', value: 40, value_in_business_hours: 15)
|
||||
|
||||
expect(described_class.metrics_for(event)).to eq(
|
||||
reply_time: {
|
||||
count: 1,
|
||||
sum_value: 40.0,
|
||||
sum_value_business_hours: 15.0
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the emitted rollup metrics for conversation_bot_resolved' do
|
||||
event = instance_double(ReportingEvent, name: 'conversation_bot_resolved')
|
||||
|
||||
expect(described_class.metrics_for(event)).to eq(
|
||||
bot_resolutions_count: {
|
||||
count: 1,
|
||||
sum_value: 0,
|
||||
sum_value_business_hours: 0
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the emitted rollup metrics for conversation_bot_handoff' do
|
||||
event = instance_double(ReportingEvent, name: 'conversation_bot_handoff')
|
||||
|
||||
expect(described_class.metrics_for(event)).to eq(
|
||||
bot_handoffs_count: {
|
||||
count: 1,
|
||||
sum_value: 0,
|
||||
sum_value_business_hours: 0
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns an empty hash for unsupported events' do
|
||||
event = instance_double(ReportingEvent, name: 'conversation_created')
|
||||
|
||||
expect(described_class.metrics_for(event)).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
describe '.metrics_for_aggregate' do
|
||||
it 'returns aggregated rollup metrics for conversation_resolved groups' do
|
||||
expect(
|
||||
described_class.metrics_for_aggregate(
|
||||
'conversation_resolved',
|
||||
count: 3,
|
||||
sum_value: 420,
|
||||
sum_value_business_hours: 210
|
||||
)
|
||||
).to eq(
|
||||
resolutions_count: {
|
||||
count: 3,
|
||||
sum_value: 0,
|
||||
sum_value_business_hours: 0
|
||||
},
|
||||
resolution_time: {
|
||||
count: 3,
|
||||
sum_value: 420.0,
|
||||
sum_value_business_hours: 210.0
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns an empty hash for unsupported grouped events' do
|
||||
expect(
|
||||
described_class.metrics_for_aggregate(
|
||||
'conversation_created',
|
||||
count: 2,
|
||||
sum_value: 100,
|
||||
sum_value_business_hours: 50
|
||||
)
|
||||
).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,446 @@
|
||||
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) }
|
||||
|
||||
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(:rollup_event_created_at) { Time.utc(2026, 2, 12, 4, 0, 0) }
|
||||
let(:rollup_write_time) { Time.utc(2026, 2, 12, 10, 0, 0) }
|
||||
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
|
||||
let(:expected_upsert_options) do
|
||||
{
|
||||
unique_by: %i[account_id date dimension_type dimension_id metric],
|
||||
on_duplicate: '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
|
||||
|
||||
it 'creates rollup rows for account, agent, and inbox' 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 'batches all rollup rows in a single upsert_all call' do
|
||||
reporting_event.update!(created_at: rollup_event_created_at)
|
||||
|
||||
travel_to(rollup_write_time) do
|
||||
captured_upsert = capture_upsert_all_call
|
||||
|
||||
described_class.perform(reporting_event)
|
||||
|
||||
expect(ReportingEventsRollup).to have_received(:upsert_all).once
|
||||
expect(captured_upsert[:options][:unique_by]).to eq(expected_upsert_options[:unique_by])
|
||||
expect(captured_upsert[:options][:on_duplicate].to_s.squish).to eq(expected_upsert_options[:on_duplicate])
|
||||
expect(captured_upsert[:rows]).to match_array(expected_rollup_rows)
|
||||
end
|
||||
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
|
||||
|
||||
def capture_upsert_all_call
|
||||
{}.tap do |captured_upsert|
|
||||
allow(ReportingEventsRollup).to receive(:upsert_all) do |rows, **options|
|
||||
captured_upsert[:rows] = rows
|
||||
captured_upsert[:options] = options
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def expected_rollup_rows
|
||||
{
|
||||
account: account.id,
|
||||
agent: user.id,
|
||||
inbox: inbox.id
|
||||
}.flat_map do |dimension_type, dimension_id|
|
||||
[
|
||||
rollup_row(dimension_type, dimension_id, :resolutions_count, 0.0, 0.0),
|
||||
rollup_row(dimension_type, dimension_id, :resolution_time, 1000.0, 500.0)
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
def rollup_row(dimension_type, dimension_id, metric, sum_value, sum_value_business_hours)
|
||||
{
|
||||
account_id: account.id,
|
||||
date: Date.new(2026, 2, 11),
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
metric: metric,
|
||||
count: 1,
|
||||
sum_value: sum_value,
|
||||
sum_value_business_hours: sum_value_business_hours,
|
||||
created_at: rollup_write_time,
|
||||
updated_at: rollup_write_time
|
||||
}
|
||||
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
|
||||
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
|
||||
@@ -82,10 +82,16 @@ audit_log:
|
||||
## public resources
|
||||
public_contact:
|
||||
$ref: ./resource/public/contact.yml
|
||||
public_contact_record:
|
||||
$ref: ./resource/public/contact_record.yml
|
||||
public_conversation:
|
||||
$ref: ./resource/public/conversation.yml
|
||||
public_message:
|
||||
$ref: ./resource/public/message.yml
|
||||
public_message_attachment:
|
||||
$ref: ./resource/public/message_attachment.yml
|
||||
public_message_sender:
|
||||
$ref: ./resource/public/message_sender.yml
|
||||
public_inbox:
|
||||
$ref: ./resource/public/inbox.yml
|
||||
|
||||
|
||||
@@ -7,11 +7,20 @@ properties:
|
||||
type: string
|
||||
description: The session identifier of the contact
|
||||
name:
|
||||
type: string
|
||||
description: Name of the contact
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Name of the contact when available
|
||||
email:
|
||||
type: string
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Email of the contact
|
||||
phone_number:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Phone number of the contact
|
||||
pubsub_token:
|
||||
type: string
|
||||
description: The token to be used to connect to chatwoot websocket
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Full serialized contact record returned when the public API renders a Contact model directly.
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: Id of the contact
|
||||
name:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Name of the contact when available
|
||||
email:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Email of the contact
|
||||
phone_number:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Phone number of the contact
|
||||
identifier:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Identifier of the contact
|
||||
blocked:
|
||||
type: boolean
|
||||
description: Whether the contact is blocked
|
||||
additional_attributes:
|
||||
type:
|
||||
- object
|
||||
- 'null'
|
||||
description: Additional attributes of the contact when present
|
||||
custom_attributes:
|
||||
type:
|
||||
- object
|
||||
- 'null'
|
||||
description: Custom attributes of the contact when present
|
||||
contact_type:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Contact type of the contact when available
|
||||
country_code:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Country code of the contact
|
||||
last_activity_at:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Last activity timestamp of the contact in ISO 8601 format
|
||||
created_at:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Created timestamp of the contact in ISO 8601 format
|
||||
updated_at:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Updated timestamp of the contact in ISO 8601 format
|
||||
last_name:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Last name of the contact
|
||||
middle_name:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Middle name of the contact
|
||||
location:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Location of the contact
|
||||
account_id:
|
||||
type: integer
|
||||
description: Account id of the contact
|
||||
company_id:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
description: Company id of the contact
|
||||
label_list:
|
||||
type: array
|
||||
description: Labels applied to the contact
|
||||
items:
|
||||
type: string
|
||||
@@ -3,14 +3,26 @@ properties:
|
||||
id:
|
||||
type: integer
|
||||
description: Id of the conversation
|
||||
inbox_id:
|
||||
uuid:
|
||||
type: string
|
||||
description: UUID of the conversation
|
||||
inbox_id:
|
||||
type: integer
|
||||
description: The inbox id of the conversation
|
||||
contact_last_seen_at:
|
||||
type: integer
|
||||
description: Timestamp of when the contact last seen the conversation (Unix timestamp)
|
||||
status:
|
||||
type: string
|
||||
enum: ['open', 'resolved', 'pending', 'snoozed']
|
||||
description: The status of the conversation
|
||||
agent_last_seen_at:
|
||||
type: integer
|
||||
description: Timestamp of when the agent last seen the conversation (Unix timestamp)
|
||||
messages:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/message'
|
||||
$ref: '#/components/schemas/public_message'
|
||||
description: Messages in the conversation
|
||||
contact:
|
||||
type: object
|
||||
description: The contact information associated to the conversation
|
||||
$ref: '#/components/schemas/public_contact_record'
|
||||
|
||||
@@ -1,38 +1,32 @@
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
type: integer
|
||||
description: Id of the message
|
||||
content:
|
||||
type: string
|
||||
description: Text content of the message
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Text content of the message. Can be null for attachment-only messages.
|
||||
message_type:
|
||||
type: string
|
||||
description: Denotes the message type
|
||||
type: integer
|
||||
description: "Denotes the message type. Possible values: 0 (incoming), 1 (outgoing), 2 (activity), 3 (template)"
|
||||
content_type:
|
||||
type: string
|
||||
description: Content type of the message
|
||||
content_attributes:
|
||||
type: string
|
||||
type: object
|
||||
description: Additional content attributes of the message
|
||||
created_at:
|
||||
type: string
|
||||
description: Created at time stamp of the message
|
||||
type: integer
|
||||
description: Created at Unix timestamp of the message
|
||||
conversation_id:
|
||||
type: string
|
||||
description: Conversation Id of the message
|
||||
type: integer
|
||||
description: Display Id of the conversation the message belongs to
|
||||
attachments:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
description: Attachments if any
|
||||
items:
|
||||
$ref: '#/components/schemas/public_message_attachment'
|
||||
sender:
|
||||
type: object
|
||||
description: Details of the sender
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
$ref: '#/components/schemas/public_message_sender'
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Attachment payload. Available fields vary by attachment file_type.
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: Id of the attachment
|
||||
message_id:
|
||||
type: integer
|
||||
description: Id of the parent message
|
||||
file_type:
|
||||
type: string
|
||||
enum: ['image', 'audio', 'video', 'file', 'location', 'fallback', 'share', 'story_mention', 'contact', 'ig_reel', 'ig_post', 'ig_story', 'embed']
|
||||
description: Type of the attached file
|
||||
account_id:
|
||||
type: integer
|
||||
description: Id of the account
|
||||
extension:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: File extension
|
||||
data_url:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: URL of the file. Can be null when an attachment variant has no external URL.
|
||||
thumb_url:
|
||||
type: string
|
||||
description: URL of the file thumbnail
|
||||
file_size:
|
||||
type: integer
|
||||
description: File size in bytes
|
||||
width:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
description: Width of the attachment when available
|
||||
height:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
description: Height of the attachment when available
|
||||
coordinates_lat:
|
||||
type: number
|
||||
description: Latitude for location attachments
|
||||
coordinates_long:
|
||||
type: number
|
||||
description: Longitude for location attachments
|
||||
fallback_title:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Fallback title for location, fallback, and contact attachments when available
|
||||
meta:
|
||||
type: object
|
||||
description: Metadata for contact attachments
|
||||
transcribed_text:
|
||||
type: string
|
||||
description: Transcribed text for audio attachments
|
||||
@@ -0,0 +1,70 @@
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Polymorphic sender payload returned by push_event_data. Available fields vary by sender type.
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: Id of the sender
|
||||
name:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Name of the sender when available
|
||||
avatar_url:
|
||||
type: string
|
||||
description: Avatar URL of the sender. Present for senders of type user, agent_bot, and captain_assistant. Not present for contact senders (use thumbnail instead).
|
||||
thumbnail:
|
||||
type: string
|
||||
description: Avatar/thumbnail URL of the sender. Contact senders use this field; user senders may also include it.
|
||||
type:
|
||||
type: string
|
||||
enum: ['contact', 'user', 'agent_bot', 'captain_assistant']
|
||||
description: Type of the sender
|
||||
available_name:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Display name for user senders
|
||||
availability_status:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Availability status for user senders
|
||||
email:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Email of the sender when the sender is a contact
|
||||
phone_number:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Phone number of the sender when the sender is a contact
|
||||
identifier:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Identifier of the sender when the sender is a contact
|
||||
blocked:
|
||||
type: boolean
|
||||
description: Whether the sender is blocked when the sender is a contact
|
||||
additional_attributes:
|
||||
type:
|
||||
- object
|
||||
- 'null'
|
||||
description: Additional attributes when the sender is a contact and available
|
||||
custom_attributes:
|
||||
type:
|
||||
- object
|
||||
- 'null'
|
||||
description: Custom attributes when the sender is a contact and available
|
||||
description:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Description when the sender is a captain assistant
|
||||
created_at:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Created timestamp in ISO 8601 format when the sender is a captain assistant
|
||||
@@ -16,7 +16,7 @@ responses:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/public_contact'
|
||||
$ref: '#/components/schemas/public_contact_record'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
|
||||
+389
-22
@@ -971,7 +971,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/public_contact"
|
||||
"$ref": "#/components/schemas/public_contact_record"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10750,19 +10750,163 @@
|
||||
"description": "The session identifier of the contact"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the contact"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the contact when available"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the contact"
|
||||
},
|
||||
"pubsub_token": {
|
||||
"type": "string",
|
||||
"description": "The token to be used to connect to chatwoot websocket"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_contact_record": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Full serialized contact record returned when the public API renders a Contact model directly.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the contact"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the contact when available"
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the contact"
|
||||
},
|
||||
"identifier": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Identifier of the contact"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the contact is blocked"
|
||||
},
|
||||
"additional_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Additional attributes of the contact when present"
|
||||
},
|
||||
"custom_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Custom attributes of the contact when present"
|
||||
},
|
||||
"contact_type": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Contact type of the contact when available"
|
||||
},
|
||||
"country_code": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Country code of the contact"
|
||||
},
|
||||
"last_activity_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Last activity timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"created_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Created timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Updated timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"last_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Last name of the contact"
|
||||
},
|
||||
"middle_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Middle name of the contact"
|
||||
},
|
||||
"location": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Location of the contact"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "integer",
|
||||
"description": "Account id of the contact"
|
||||
},
|
||||
"company_id": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Company id of the contact"
|
||||
},
|
||||
"label_list": {
|
||||
"type": "array",
|
||||
"description": "Labels applied to the contact",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_conversation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -10770,20 +10914,41 @@
|
||||
"type": "integer",
|
||||
"description": "Id of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"uuid": {
|
||||
"type": "string",
|
||||
"description": "UUID of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"type": "integer",
|
||||
"description": "The inbox id of the conversation"
|
||||
},
|
||||
"contact_last_seen_at": {
|
||||
"type": "integer",
|
||||
"description": "Timestamp of when the contact last seen the conversation (Unix timestamp)"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"open",
|
||||
"resolved",
|
||||
"pending",
|
||||
"snoozed"
|
||||
],
|
||||
"description": "The status of the conversation"
|
||||
},
|
||||
"agent_last_seen_at": {
|
||||
"type": "integer",
|
||||
"description": "Timestamp of when the agent last seen the conversation (Unix timestamp)"
|
||||
},
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/message"
|
||||
"$ref": "#/components/schemas/public_message"
|
||||
},
|
||||
"description": "Messages in the conversation"
|
||||
},
|
||||
"contact": {
|
||||
"type": "object",
|
||||
"description": "The contact information associated to the conversation"
|
||||
"$ref": "#/components/schemas/public_contact_record"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -10791,43 +10956,245 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"type": "integer",
|
||||
"description": "Id of the message"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Text content of the message"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Text content of the message. Can be null for attachment-only messages."
|
||||
},
|
||||
"message_type": {
|
||||
"type": "string",
|
||||
"description": "Denotes the message type"
|
||||
"type": "integer",
|
||||
"description": "Denotes the message type. Possible values: 0 (incoming), 1 (outgoing), 2 (activity), 3 (template)"
|
||||
},
|
||||
"content_type": {
|
||||
"type": "string",
|
||||
"description": "Content type of the message"
|
||||
},
|
||||
"content_attributes": {
|
||||
"type": "string",
|
||||
"type": "object",
|
||||
"description": "Additional content attributes of the message"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"description": "Created at time stamp of the message"
|
||||
"type": "integer",
|
||||
"description": "Created at Unix timestamp of the message"
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "string",
|
||||
"description": "Conversation Id of the message"
|
||||
"type": "integer",
|
||||
"description": "Display Id of the conversation the message belongs to"
|
||||
},
|
||||
"attachments": {
|
||||
"type": "array",
|
||||
"description": "Attachments if any",
|
||||
"items": {
|
||||
"type": "object"
|
||||
},
|
||||
"description": "Attachments if any"
|
||||
"$ref": "#/components/schemas/public_message_attachment"
|
||||
}
|
||||
},
|
||||
"sender": {
|
||||
"$ref": "#/components/schemas/public_message_sender"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_message_attachment": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Attachment payload. Available fields vary by attachment file_type.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the attachment"
|
||||
},
|
||||
"message_id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the parent message"
|
||||
},
|
||||
"file_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"audio",
|
||||
"video",
|
||||
"file",
|
||||
"location",
|
||||
"fallback",
|
||||
"share",
|
||||
"story_mention",
|
||||
"contact",
|
||||
"ig_reel",
|
||||
"ig_post",
|
||||
"ig_story",
|
||||
"embed"
|
||||
],
|
||||
"description": "Type of the attached file"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the account"
|
||||
},
|
||||
"extension": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "File extension"
|
||||
},
|
||||
"data_url": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "URL of the file. Can be null when an attachment variant has no external URL."
|
||||
},
|
||||
"thumb_url": {
|
||||
"type": "string",
|
||||
"description": "URL of the file thumbnail"
|
||||
},
|
||||
"file_size": {
|
||||
"type": "integer",
|
||||
"description": "File size in bytes"
|
||||
},
|
||||
"width": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Width of the attachment when available"
|
||||
},
|
||||
"height": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Height of the attachment when available"
|
||||
},
|
||||
"coordinates_lat": {
|
||||
"type": "number",
|
||||
"description": "Latitude for location attachments"
|
||||
},
|
||||
"coordinates_long": {
|
||||
"type": "number",
|
||||
"description": "Longitude for location attachments"
|
||||
},
|
||||
"fallback_title": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Fallback title for location, fallback, and contact attachments when available"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"description": "Details of the sender"
|
||||
"description": "Metadata for contact attachments"
|
||||
},
|
||||
"transcribed_text": {
|
||||
"type": "string",
|
||||
"description": "Transcribed text for audio attachments"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_message_sender": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Polymorphic sender payload returned by push_event_data. Available fields vary by sender type.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the sender"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the sender when available"
|
||||
},
|
||||
"avatar_url": {
|
||||
"type": "string",
|
||||
"description": "Avatar URL of the sender. Present for senders of type user, agent_bot, and captain_assistant. Not present for contact senders (use thumbnail instead)."
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string",
|
||||
"description": "Avatar/thumbnail URL of the sender. Contact senders use this field; user senders may also include it."
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"contact",
|
||||
"user",
|
||||
"agent_bot",
|
||||
"captain_assistant"
|
||||
],
|
||||
"description": "Type of the sender"
|
||||
},
|
||||
"available_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Display name for user senders"
|
||||
},
|
||||
"availability_status": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Availability status for user senders"
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the sender when the sender is a contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the sender when the sender is a contact"
|
||||
},
|
||||
"identifier": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Identifier of the sender when the sender is a contact"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the sender is blocked when the sender is a contact"
|
||||
},
|
||||
"additional_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Additional attributes when the sender is a contact and available"
|
||||
},
|
||||
"custom_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Custom attributes when the sender is a contact and available"
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Description when the sender is a captain assistant"
|
||||
},
|
||||
"created_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Created timestamp in ISO 8601 format when the sender is a captain assistant"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9257,19 +9257,163 @@
|
||||
"description": "The session identifier of the contact"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the contact"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the contact when available"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the contact"
|
||||
},
|
||||
"pubsub_token": {
|
||||
"type": "string",
|
||||
"description": "The token to be used to connect to chatwoot websocket"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_contact_record": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Full serialized contact record returned when the public API renders a Contact model directly.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the contact"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the contact when available"
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the contact"
|
||||
},
|
||||
"identifier": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Identifier of the contact"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the contact is blocked"
|
||||
},
|
||||
"additional_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Additional attributes of the contact when present"
|
||||
},
|
||||
"custom_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Custom attributes of the contact when present"
|
||||
},
|
||||
"contact_type": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Contact type of the contact when available"
|
||||
},
|
||||
"country_code": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Country code of the contact"
|
||||
},
|
||||
"last_activity_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Last activity timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"created_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Created timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Updated timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"last_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Last name of the contact"
|
||||
},
|
||||
"middle_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Middle name of the contact"
|
||||
},
|
||||
"location": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Location of the contact"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "integer",
|
||||
"description": "Account id of the contact"
|
||||
},
|
||||
"company_id": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Company id of the contact"
|
||||
},
|
||||
"label_list": {
|
||||
"type": "array",
|
||||
"description": "Labels applied to the contact",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_conversation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9277,20 +9421,41 @@
|
||||
"type": "integer",
|
||||
"description": "Id of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"uuid": {
|
||||
"type": "string",
|
||||
"description": "UUID of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"type": "integer",
|
||||
"description": "The inbox id of the conversation"
|
||||
},
|
||||
"contact_last_seen_at": {
|
||||
"type": "integer",
|
||||
"description": "Timestamp of when the contact last seen the conversation (Unix timestamp)"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"open",
|
||||
"resolved",
|
||||
"pending",
|
||||
"snoozed"
|
||||
],
|
||||
"description": "The status of the conversation"
|
||||
},
|
||||
"agent_last_seen_at": {
|
||||
"type": "integer",
|
||||
"description": "Timestamp of when the agent last seen the conversation (Unix timestamp)"
|
||||
},
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/message"
|
||||
"$ref": "#/components/schemas/public_message"
|
||||
},
|
||||
"description": "Messages in the conversation"
|
||||
},
|
||||
"contact": {
|
||||
"type": "object",
|
||||
"description": "The contact information associated to the conversation"
|
||||
"$ref": "#/components/schemas/public_contact_record"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9298,43 +9463,245 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"type": "integer",
|
||||
"description": "Id of the message"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Text content of the message"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Text content of the message. Can be null for attachment-only messages."
|
||||
},
|
||||
"message_type": {
|
||||
"type": "string",
|
||||
"description": "Denotes the message type"
|
||||
"type": "integer",
|
||||
"description": "Denotes the message type. Possible values: 0 (incoming), 1 (outgoing), 2 (activity), 3 (template)"
|
||||
},
|
||||
"content_type": {
|
||||
"type": "string",
|
||||
"description": "Content type of the message"
|
||||
},
|
||||
"content_attributes": {
|
||||
"type": "string",
|
||||
"type": "object",
|
||||
"description": "Additional content attributes of the message"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"description": "Created at time stamp of the message"
|
||||
"type": "integer",
|
||||
"description": "Created at Unix timestamp of the message"
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "string",
|
||||
"description": "Conversation Id of the message"
|
||||
"type": "integer",
|
||||
"description": "Display Id of the conversation the message belongs to"
|
||||
},
|
||||
"attachments": {
|
||||
"type": "array",
|
||||
"description": "Attachments if any",
|
||||
"items": {
|
||||
"type": "object"
|
||||
},
|
||||
"description": "Attachments if any"
|
||||
"$ref": "#/components/schemas/public_message_attachment"
|
||||
}
|
||||
},
|
||||
"sender": {
|
||||
"$ref": "#/components/schemas/public_message_sender"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_message_attachment": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Attachment payload. Available fields vary by attachment file_type.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the attachment"
|
||||
},
|
||||
"message_id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the parent message"
|
||||
},
|
||||
"file_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"audio",
|
||||
"video",
|
||||
"file",
|
||||
"location",
|
||||
"fallback",
|
||||
"share",
|
||||
"story_mention",
|
||||
"contact",
|
||||
"ig_reel",
|
||||
"ig_post",
|
||||
"ig_story",
|
||||
"embed"
|
||||
],
|
||||
"description": "Type of the attached file"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the account"
|
||||
},
|
||||
"extension": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "File extension"
|
||||
},
|
||||
"data_url": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "URL of the file. Can be null when an attachment variant has no external URL."
|
||||
},
|
||||
"thumb_url": {
|
||||
"type": "string",
|
||||
"description": "URL of the file thumbnail"
|
||||
},
|
||||
"file_size": {
|
||||
"type": "integer",
|
||||
"description": "File size in bytes"
|
||||
},
|
||||
"width": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Width of the attachment when available"
|
||||
},
|
||||
"height": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Height of the attachment when available"
|
||||
},
|
||||
"coordinates_lat": {
|
||||
"type": "number",
|
||||
"description": "Latitude for location attachments"
|
||||
},
|
||||
"coordinates_long": {
|
||||
"type": "number",
|
||||
"description": "Longitude for location attachments"
|
||||
},
|
||||
"fallback_title": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Fallback title for location, fallback, and contact attachments when available"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"description": "Details of the sender"
|
||||
"description": "Metadata for contact attachments"
|
||||
},
|
||||
"transcribed_text": {
|
||||
"type": "string",
|
||||
"description": "Transcribed text for audio attachments"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_message_sender": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Polymorphic sender payload returned by push_event_data. Available fields vary by sender type.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the sender"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the sender when available"
|
||||
},
|
||||
"avatar_url": {
|
||||
"type": "string",
|
||||
"description": "Avatar URL of the sender. Present for senders of type user, agent_bot, and captain_assistant. Not present for contact senders (use thumbnail instead)."
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string",
|
||||
"description": "Avatar/thumbnail URL of the sender. Contact senders use this field; user senders may also include it."
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"contact",
|
||||
"user",
|
||||
"agent_bot",
|
||||
"captain_assistant"
|
||||
],
|
||||
"description": "Type of the sender"
|
||||
},
|
||||
"available_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Display name for user senders"
|
||||
},
|
||||
"availability_status": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Availability status for user senders"
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the sender when the sender is a contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the sender when the sender is a contact"
|
||||
},
|
||||
"identifier": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Identifier of the sender when the sender is a contact"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the sender is blocked when the sender is a contact"
|
||||
},
|
||||
"additional_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Additional attributes when the sender is a contact and available"
|
||||
},
|
||||
"custom_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Custom attributes when the sender is a contact and available"
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Description when the sender is a captain assistant"
|
||||
},
|
||||
"created_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Created timestamp in ISO 8601 format when the sender is a captain assistant"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/public_contact"
|
||||
"$ref": "#/components/schemas/public_contact_record"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2522,19 +2522,163 @@
|
||||
"description": "The session identifier of the contact"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the contact"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the contact when available"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the contact"
|
||||
},
|
||||
"pubsub_token": {
|
||||
"type": "string",
|
||||
"description": "The token to be used to connect to chatwoot websocket"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_contact_record": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Full serialized contact record returned when the public API renders a Contact model directly.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the contact"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the contact when available"
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the contact"
|
||||
},
|
||||
"identifier": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Identifier of the contact"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the contact is blocked"
|
||||
},
|
||||
"additional_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Additional attributes of the contact when present"
|
||||
},
|
||||
"custom_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Custom attributes of the contact when present"
|
||||
},
|
||||
"contact_type": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Contact type of the contact when available"
|
||||
},
|
||||
"country_code": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Country code of the contact"
|
||||
},
|
||||
"last_activity_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Last activity timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"created_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Created timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Updated timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"last_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Last name of the contact"
|
||||
},
|
||||
"middle_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Middle name of the contact"
|
||||
},
|
||||
"location": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Location of the contact"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "integer",
|
||||
"description": "Account id of the contact"
|
||||
},
|
||||
"company_id": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Company id of the contact"
|
||||
},
|
||||
"label_list": {
|
||||
"type": "array",
|
||||
"description": "Labels applied to the contact",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_conversation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2542,20 +2686,41 @@
|
||||
"type": "integer",
|
||||
"description": "Id of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"uuid": {
|
||||
"type": "string",
|
||||
"description": "UUID of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"type": "integer",
|
||||
"description": "The inbox id of the conversation"
|
||||
},
|
||||
"contact_last_seen_at": {
|
||||
"type": "integer",
|
||||
"description": "Timestamp of when the contact last seen the conversation (Unix timestamp)"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"open",
|
||||
"resolved",
|
||||
"pending",
|
||||
"snoozed"
|
||||
],
|
||||
"description": "The status of the conversation"
|
||||
},
|
||||
"agent_last_seen_at": {
|
||||
"type": "integer",
|
||||
"description": "Timestamp of when the agent last seen the conversation (Unix timestamp)"
|
||||
},
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/message"
|
||||
"$ref": "#/components/schemas/public_message"
|
||||
},
|
||||
"description": "Messages in the conversation"
|
||||
},
|
||||
"contact": {
|
||||
"type": "object",
|
||||
"description": "The contact information associated to the conversation"
|
||||
"$ref": "#/components/schemas/public_contact_record"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2563,43 +2728,245 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"type": "integer",
|
||||
"description": "Id of the message"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Text content of the message"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Text content of the message. Can be null for attachment-only messages."
|
||||
},
|
||||
"message_type": {
|
||||
"type": "string",
|
||||
"description": "Denotes the message type"
|
||||
"type": "integer",
|
||||
"description": "Denotes the message type. Possible values: 0 (incoming), 1 (outgoing), 2 (activity), 3 (template)"
|
||||
},
|
||||
"content_type": {
|
||||
"type": "string",
|
||||
"description": "Content type of the message"
|
||||
},
|
||||
"content_attributes": {
|
||||
"type": "string",
|
||||
"type": "object",
|
||||
"description": "Additional content attributes of the message"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"description": "Created at time stamp of the message"
|
||||
"type": "integer",
|
||||
"description": "Created at Unix timestamp of the message"
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "string",
|
||||
"description": "Conversation Id of the message"
|
||||
"type": "integer",
|
||||
"description": "Display Id of the conversation the message belongs to"
|
||||
},
|
||||
"attachments": {
|
||||
"type": "array",
|
||||
"description": "Attachments if any",
|
||||
"items": {
|
||||
"type": "object"
|
||||
},
|
||||
"description": "Attachments if any"
|
||||
"$ref": "#/components/schemas/public_message_attachment"
|
||||
}
|
||||
},
|
||||
"sender": {
|
||||
"$ref": "#/components/schemas/public_message_sender"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_message_attachment": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Attachment payload. Available fields vary by attachment file_type.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the attachment"
|
||||
},
|
||||
"message_id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the parent message"
|
||||
},
|
||||
"file_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"audio",
|
||||
"video",
|
||||
"file",
|
||||
"location",
|
||||
"fallback",
|
||||
"share",
|
||||
"story_mention",
|
||||
"contact",
|
||||
"ig_reel",
|
||||
"ig_post",
|
||||
"ig_story",
|
||||
"embed"
|
||||
],
|
||||
"description": "Type of the attached file"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the account"
|
||||
},
|
||||
"extension": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "File extension"
|
||||
},
|
||||
"data_url": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "URL of the file. Can be null when an attachment variant has no external URL."
|
||||
},
|
||||
"thumb_url": {
|
||||
"type": "string",
|
||||
"description": "URL of the file thumbnail"
|
||||
},
|
||||
"file_size": {
|
||||
"type": "integer",
|
||||
"description": "File size in bytes"
|
||||
},
|
||||
"width": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Width of the attachment when available"
|
||||
},
|
||||
"height": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Height of the attachment when available"
|
||||
},
|
||||
"coordinates_lat": {
|
||||
"type": "number",
|
||||
"description": "Latitude for location attachments"
|
||||
},
|
||||
"coordinates_long": {
|
||||
"type": "number",
|
||||
"description": "Longitude for location attachments"
|
||||
},
|
||||
"fallback_title": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Fallback title for location, fallback, and contact attachments when available"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"description": "Details of the sender"
|
||||
"description": "Metadata for contact attachments"
|
||||
},
|
||||
"transcribed_text": {
|
||||
"type": "string",
|
||||
"description": "Transcribed text for audio attachments"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_message_sender": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Polymorphic sender payload returned by push_event_data. Available fields vary by sender type.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the sender"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the sender when available"
|
||||
},
|
||||
"avatar_url": {
|
||||
"type": "string",
|
||||
"description": "Avatar URL of the sender. Present for senders of type user, agent_bot, and captain_assistant. Not present for contact senders (use thumbnail instead)."
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string",
|
||||
"description": "Avatar/thumbnail URL of the sender. Contact senders use this field; user senders may also include it."
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"contact",
|
||||
"user",
|
||||
"agent_bot",
|
||||
"captain_assistant"
|
||||
],
|
||||
"description": "Type of the sender"
|
||||
},
|
||||
"available_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Display name for user senders"
|
||||
},
|
||||
"availability_status": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Availability status for user senders"
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the sender when the sender is a contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the sender when the sender is a contact"
|
||||
},
|
||||
"identifier": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Identifier of the sender when the sender is a contact"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the sender is blocked when the sender is a contact"
|
||||
},
|
||||
"additional_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Additional attributes when the sender is a contact and available"
|
||||
},
|
||||
"custom_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Custom attributes when the sender is a contact and available"
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Description when the sender is a captain assistant"
|
||||
},
|
||||
"created_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Created timestamp in ISO 8601 format when the sender is a captain assistant"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1937,19 +1937,163 @@
|
||||
"description": "The session identifier of the contact"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the contact"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the contact when available"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the contact"
|
||||
},
|
||||
"pubsub_token": {
|
||||
"type": "string",
|
||||
"description": "The token to be used to connect to chatwoot websocket"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_contact_record": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Full serialized contact record returned when the public API renders a Contact model directly.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the contact"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the contact when available"
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the contact"
|
||||
},
|
||||
"identifier": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Identifier of the contact"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the contact is blocked"
|
||||
},
|
||||
"additional_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Additional attributes of the contact when present"
|
||||
},
|
||||
"custom_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Custom attributes of the contact when present"
|
||||
},
|
||||
"contact_type": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Contact type of the contact when available"
|
||||
},
|
||||
"country_code": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Country code of the contact"
|
||||
},
|
||||
"last_activity_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Last activity timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"created_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Created timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Updated timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"last_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Last name of the contact"
|
||||
},
|
||||
"middle_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Middle name of the contact"
|
||||
},
|
||||
"location": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Location of the contact"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "integer",
|
||||
"description": "Account id of the contact"
|
||||
},
|
||||
"company_id": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Company id of the contact"
|
||||
},
|
||||
"label_list": {
|
||||
"type": "array",
|
||||
"description": "Labels applied to the contact",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_conversation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1957,20 +2101,41 @@
|
||||
"type": "integer",
|
||||
"description": "Id of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"uuid": {
|
||||
"type": "string",
|
||||
"description": "UUID of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"type": "integer",
|
||||
"description": "The inbox id of the conversation"
|
||||
},
|
||||
"contact_last_seen_at": {
|
||||
"type": "integer",
|
||||
"description": "Timestamp of when the contact last seen the conversation (Unix timestamp)"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"open",
|
||||
"resolved",
|
||||
"pending",
|
||||
"snoozed"
|
||||
],
|
||||
"description": "The status of the conversation"
|
||||
},
|
||||
"agent_last_seen_at": {
|
||||
"type": "integer",
|
||||
"description": "Timestamp of when the agent last seen the conversation (Unix timestamp)"
|
||||
},
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/message"
|
||||
"$ref": "#/components/schemas/public_message"
|
||||
},
|
||||
"description": "Messages in the conversation"
|
||||
},
|
||||
"contact": {
|
||||
"type": "object",
|
||||
"description": "The contact information associated to the conversation"
|
||||
"$ref": "#/components/schemas/public_contact_record"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1978,43 +2143,245 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"type": "integer",
|
||||
"description": "Id of the message"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Text content of the message"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Text content of the message. Can be null for attachment-only messages."
|
||||
},
|
||||
"message_type": {
|
||||
"type": "string",
|
||||
"description": "Denotes the message type"
|
||||
"type": "integer",
|
||||
"description": "Denotes the message type. Possible values: 0 (incoming), 1 (outgoing), 2 (activity), 3 (template)"
|
||||
},
|
||||
"content_type": {
|
||||
"type": "string",
|
||||
"description": "Content type of the message"
|
||||
},
|
||||
"content_attributes": {
|
||||
"type": "string",
|
||||
"type": "object",
|
||||
"description": "Additional content attributes of the message"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"description": "Created at time stamp of the message"
|
||||
"type": "integer",
|
||||
"description": "Created at Unix timestamp of the message"
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "string",
|
||||
"description": "Conversation Id of the message"
|
||||
"type": "integer",
|
||||
"description": "Display Id of the conversation the message belongs to"
|
||||
},
|
||||
"attachments": {
|
||||
"type": "array",
|
||||
"description": "Attachments if any",
|
||||
"items": {
|
||||
"type": "object"
|
||||
},
|
||||
"description": "Attachments if any"
|
||||
"$ref": "#/components/schemas/public_message_attachment"
|
||||
}
|
||||
},
|
||||
"sender": {
|
||||
"$ref": "#/components/schemas/public_message_sender"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_message_attachment": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Attachment payload. Available fields vary by attachment file_type.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the attachment"
|
||||
},
|
||||
"message_id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the parent message"
|
||||
},
|
||||
"file_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"audio",
|
||||
"video",
|
||||
"file",
|
||||
"location",
|
||||
"fallback",
|
||||
"share",
|
||||
"story_mention",
|
||||
"contact",
|
||||
"ig_reel",
|
||||
"ig_post",
|
||||
"ig_story",
|
||||
"embed"
|
||||
],
|
||||
"description": "Type of the attached file"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the account"
|
||||
},
|
||||
"extension": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "File extension"
|
||||
},
|
||||
"data_url": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "URL of the file. Can be null when an attachment variant has no external URL."
|
||||
},
|
||||
"thumb_url": {
|
||||
"type": "string",
|
||||
"description": "URL of the file thumbnail"
|
||||
},
|
||||
"file_size": {
|
||||
"type": "integer",
|
||||
"description": "File size in bytes"
|
||||
},
|
||||
"width": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Width of the attachment when available"
|
||||
},
|
||||
"height": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Height of the attachment when available"
|
||||
},
|
||||
"coordinates_lat": {
|
||||
"type": "number",
|
||||
"description": "Latitude for location attachments"
|
||||
},
|
||||
"coordinates_long": {
|
||||
"type": "number",
|
||||
"description": "Longitude for location attachments"
|
||||
},
|
||||
"fallback_title": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Fallback title for location, fallback, and contact attachments when available"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"description": "Details of the sender"
|
||||
"description": "Metadata for contact attachments"
|
||||
},
|
||||
"transcribed_text": {
|
||||
"type": "string",
|
||||
"description": "Transcribed text for audio attachments"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_message_sender": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Polymorphic sender payload returned by push_event_data. Available fields vary by sender type.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the sender"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the sender when available"
|
||||
},
|
||||
"avatar_url": {
|
||||
"type": "string",
|
||||
"description": "Avatar URL of the sender. Present for senders of type user, agent_bot, and captain_assistant. Not present for contact senders (use thumbnail instead)."
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string",
|
||||
"description": "Avatar/thumbnail URL of the sender. Contact senders use this field; user senders may also include it."
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"contact",
|
||||
"user",
|
||||
"agent_bot",
|
||||
"captain_assistant"
|
||||
],
|
||||
"description": "Type of the sender"
|
||||
},
|
||||
"available_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Display name for user senders"
|
||||
},
|
||||
"availability_status": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Availability status for user senders"
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the sender when the sender is a contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the sender when the sender is a contact"
|
||||
},
|
||||
"identifier": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Identifier of the sender when the sender is a contact"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the sender is blocked when the sender is a contact"
|
||||
},
|
||||
"additional_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Additional attributes when the sender is a contact and available"
|
||||
},
|
||||
"custom_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Custom attributes when the sender is a contact and available"
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Description when the sender is a captain assistant"
|
||||
},
|
||||
"created_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Created timestamp in ISO 8601 format when the sender is a captain assistant"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2698,19 +2698,163 @@
|
||||
"description": "The session identifier of the contact"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the contact"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the contact when available"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the contact"
|
||||
},
|
||||
"pubsub_token": {
|
||||
"type": "string",
|
||||
"description": "The token to be used to connect to chatwoot websocket"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_contact_record": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Full serialized contact record returned when the public API renders a Contact model directly.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the contact"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the contact when available"
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the contact"
|
||||
},
|
||||
"identifier": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Identifier of the contact"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the contact is blocked"
|
||||
},
|
||||
"additional_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Additional attributes of the contact when present"
|
||||
},
|
||||
"custom_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Custom attributes of the contact when present"
|
||||
},
|
||||
"contact_type": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Contact type of the contact when available"
|
||||
},
|
||||
"country_code": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Country code of the contact"
|
||||
},
|
||||
"last_activity_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Last activity timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"created_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Created timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Updated timestamp of the contact in ISO 8601 format"
|
||||
},
|
||||
"last_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Last name of the contact"
|
||||
},
|
||||
"middle_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Middle name of the contact"
|
||||
},
|
||||
"location": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Location of the contact"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "integer",
|
||||
"description": "Account id of the contact"
|
||||
},
|
||||
"company_id": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Company id of the contact"
|
||||
},
|
||||
"label_list": {
|
||||
"type": "array",
|
||||
"description": "Labels applied to the contact",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_conversation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2718,20 +2862,41 @@
|
||||
"type": "integer",
|
||||
"description": "Id of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"uuid": {
|
||||
"type": "string",
|
||||
"description": "UUID of the conversation"
|
||||
},
|
||||
"inbox_id": {
|
||||
"type": "integer",
|
||||
"description": "The inbox id of the conversation"
|
||||
},
|
||||
"contact_last_seen_at": {
|
||||
"type": "integer",
|
||||
"description": "Timestamp of when the contact last seen the conversation (Unix timestamp)"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"open",
|
||||
"resolved",
|
||||
"pending",
|
||||
"snoozed"
|
||||
],
|
||||
"description": "The status of the conversation"
|
||||
},
|
||||
"agent_last_seen_at": {
|
||||
"type": "integer",
|
||||
"description": "Timestamp of when the agent last seen the conversation (Unix timestamp)"
|
||||
},
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/message"
|
||||
"$ref": "#/components/schemas/public_message"
|
||||
},
|
||||
"description": "Messages in the conversation"
|
||||
},
|
||||
"contact": {
|
||||
"type": "object",
|
||||
"description": "The contact information associated to the conversation"
|
||||
"$ref": "#/components/schemas/public_contact_record"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2739,43 +2904,245 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"type": "integer",
|
||||
"description": "Id of the message"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Text content of the message"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Text content of the message. Can be null for attachment-only messages."
|
||||
},
|
||||
"message_type": {
|
||||
"type": "string",
|
||||
"description": "Denotes the message type"
|
||||
"type": "integer",
|
||||
"description": "Denotes the message type. Possible values: 0 (incoming), 1 (outgoing), 2 (activity), 3 (template)"
|
||||
},
|
||||
"content_type": {
|
||||
"type": "string",
|
||||
"description": "Content type of the message"
|
||||
},
|
||||
"content_attributes": {
|
||||
"type": "string",
|
||||
"type": "object",
|
||||
"description": "Additional content attributes of the message"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"description": "Created at time stamp of the message"
|
||||
"type": "integer",
|
||||
"description": "Created at Unix timestamp of the message"
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "string",
|
||||
"description": "Conversation Id of the message"
|
||||
"type": "integer",
|
||||
"description": "Display Id of the conversation the message belongs to"
|
||||
},
|
||||
"attachments": {
|
||||
"type": "array",
|
||||
"description": "Attachments if any",
|
||||
"items": {
|
||||
"type": "object"
|
||||
},
|
||||
"description": "Attachments if any"
|
||||
"$ref": "#/components/schemas/public_message_attachment"
|
||||
}
|
||||
},
|
||||
"sender": {
|
||||
"$ref": "#/components/schemas/public_message_sender"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_message_attachment": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Attachment payload. Available fields vary by attachment file_type.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the attachment"
|
||||
},
|
||||
"message_id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the parent message"
|
||||
},
|
||||
"file_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"audio",
|
||||
"video",
|
||||
"file",
|
||||
"location",
|
||||
"fallback",
|
||||
"share",
|
||||
"story_mention",
|
||||
"contact",
|
||||
"ig_reel",
|
||||
"ig_post",
|
||||
"ig_story",
|
||||
"embed"
|
||||
],
|
||||
"description": "Type of the attached file"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the account"
|
||||
},
|
||||
"extension": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "File extension"
|
||||
},
|
||||
"data_url": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "URL of the file. Can be null when an attachment variant has no external URL."
|
||||
},
|
||||
"thumb_url": {
|
||||
"type": "string",
|
||||
"description": "URL of the file thumbnail"
|
||||
},
|
||||
"file_size": {
|
||||
"type": "integer",
|
||||
"description": "File size in bytes"
|
||||
},
|
||||
"width": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Width of the attachment when available"
|
||||
},
|
||||
"height": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Height of the attachment when available"
|
||||
},
|
||||
"coordinates_lat": {
|
||||
"type": "number",
|
||||
"description": "Latitude for location attachments"
|
||||
},
|
||||
"coordinates_long": {
|
||||
"type": "number",
|
||||
"description": "Longitude for location attachments"
|
||||
},
|
||||
"fallback_title": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Fallback title for location, fallback, and contact attachments when available"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"description": "Details of the sender"
|
||||
"description": "Metadata for contact attachments"
|
||||
},
|
||||
"transcribed_text": {
|
||||
"type": "string",
|
||||
"description": "Transcribed text for audio attachments"
|
||||
}
|
||||
}
|
||||
},
|
||||
"public_message_sender": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Polymorphic sender payload returned by push_event_data. Available fields vary by sender type.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Id of the sender"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Name of the sender when available"
|
||||
},
|
||||
"avatar_url": {
|
||||
"type": "string",
|
||||
"description": "Avatar URL of the sender. Present for senders of type user, agent_bot, and captain_assistant. Not present for contact senders (use thumbnail instead)."
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string",
|
||||
"description": "Avatar/thumbnail URL of the sender. Contact senders use this field; user senders may also include it."
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"contact",
|
||||
"user",
|
||||
"agent_bot",
|
||||
"captain_assistant"
|
||||
],
|
||||
"description": "Type of the sender"
|
||||
},
|
||||
"available_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Display name for user senders"
|
||||
},
|
||||
"availability_status": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Availability status for user senders"
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Email of the sender when the sender is a contact"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Phone number of the sender when the sender is a contact"
|
||||
},
|
||||
"identifier": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Identifier of the sender when the sender is a contact"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the sender is blocked when the sender is a contact"
|
||||
},
|
||||
"additional_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Additional attributes when the sender is a contact and available"
|
||||
},
|
||||
"custom_attributes": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Custom attributes when the sender is a contact and available"
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Description when the sender is a captain assistant"
|
||||
},
|
||||
"created_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Created timestamp in ISO 8601 format when the sender is a captain assistant"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user