Merge branch 'feat/rollup/2-report-data-source' into feat/rollup/3-rollup-read-path
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
skip_before_action :check_authorization, only: [:health, :register_webhook]
|
||||
before_action :check_admin_authorization?, only: [:register_webhook]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def register_webhook
|
||||
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
|
||||
|
||||
render json: { message: 'Webhook registered successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,8 +4,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
before_action :validate_limit, only: [:create]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
before_action :check_authorization, except: [:show, :health]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
||||
before_action :check_authorization, except: [:show]
|
||||
|
||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
@@ -70,23 +71,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -98,12 +82,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||
|
||||
@@ -200,18 +178,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
def get_channel_attributes(channel_type)
|
||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
contact = @contact
|
||||
end
|
||||
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac?
|
||||
|
||||
identify_contact(contact)
|
||||
end
|
||||
|
||||
@@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient {
|
||||
getHealthStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/health`);
|
||||
}
|
||||
|
||||
registerWebhook(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/register_webhook`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InboxHealthAPI();
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('HELP_CENTER.TITLE')"
|
||||
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
|
||||
class="bg-n-surface-1"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-2 gap-4 p-px">
|
||||
|
||||
@@ -685,6 +685,16 @@
|
||||
"SANDBOX": "Sandbox",
|
||||
"LIVE": "Live"
|
||||
}
|
||||
},
|
||||
"WEBHOOK": {
|
||||
"TITLE": "Webhook Configuration",
|
||||
"DESCRIPTION": "Webhook URL is required for your WhatsApp Business Account to receive messages from customers",
|
||||
"ACTION_REQUIRED": "Webhook not configured",
|
||||
"REGISTER_BUTTON": "Register Webhook",
|
||||
"REGISTER_SUCCESS": "Webhook registered successfully",
|
||||
"REGISTER_ERROR": "Failed to register webhook. Please try again.",
|
||||
"CONFIGURED_SUCCESS": "Webhook configured successfully",
|
||||
"URL_MISMATCH": "Webhook URL mismatch"
|
||||
}
|
||||
},
|
||||
"SETTINGS": "Settings",
|
||||
|
||||
@@ -99,6 +99,7 @@ export default {
|
||||
healthData: null,
|
||||
isLoadingHealth: false,
|
||||
healthError: null,
|
||||
isRegisteringWebhook: false,
|
||||
widgetBubblePosition: 'right',
|
||||
widgetBubbleType: 'standard',
|
||||
widgetBubbleLauncherTitle: '',
|
||||
@@ -424,6 +425,23 @@ export default {
|
||||
this.isLoadingHealth = false;
|
||||
}
|
||||
},
|
||||
async registerWebhook() {
|
||||
if (!this.inbox) return;
|
||||
|
||||
try {
|
||||
this.isRegisteringWebhook = true;
|
||||
await InboxHealthAPI.registerWebhook(this.inbox.id);
|
||||
useAlert(this.$t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_SUCCESS'));
|
||||
await this.fetchHealthData();
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message ||
|
||||
this.$t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_ERROR')
|
||||
);
|
||||
} finally {
|
||||
this.isRegisteringWebhook = false;
|
||||
}
|
||||
},
|
||||
handleFeatureFlag(e) {
|
||||
this.selectedFeatureFlags = this.toggleInput(
|
||||
this.selectedFeatureFlags,
|
||||
@@ -1162,7 +1180,11 @@ export default {
|
||||
<BotConfiguration :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'whatsapp-health'">
|
||||
<AccountHealth :health-data="healthData" />
|
||||
<AccountHealth
|
||||
:health-data="healthData"
|
||||
:is-registering-webhook="isRegisteringWebhook"
|
||||
@register-webhook="registerWebhook"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -10,8 +10,14 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isRegisteringWebhook: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['registerWebhook']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const QUALITY_COLORS = {
|
||||
@@ -133,6 +139,28 @@ const formatModeDisplay = mode =>
|
||||
const getModeStatusTextColor = mode => MODE_COLORS[mode] || 'text-n-slate-12';
|
||||
|
||||
const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
||||
|
||||
const showWebhookSection = computed(
|
||||
() => props.healthData?.webhook_configuration !== undefined
|
||||
);
|
||||
|
||||
const webhookUrl = computed(
|
||||
() =>
|
||||
props.healthData?.webhook_configuration?.whatsapp_business_account ||
|
||||
props.healthData?.webhook_configuration?.application
|
||||
);
|
||||
|
||||
const webhookConfigured = computed(() => !!webhookUrl.value);
|
||||
|
||||
const webhookUrlMismatch = computed(
|
||||
() =>
|
||||
webhookConfigured.value &&
|
||||
webhookUrl.value !== props.healthData?.expected_webhook_url
|
||||
);
|
||||
|
||||
const handleRegisterWebhook = () => {
|
||||
emit('registerWebhook');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -211,6 +239,55 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook configuration card -->
|
||||
<div
|
||||
v-if="showWebhookSection"
|
||||
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-body-main font-medium text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.TITLE') }}
|
||||
</span>
|
||||
<Icon
|
||||
v-tooltip.top="t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.DESCRIPTION')"
|
||||
icon="i-lucide-info"
|
||||
class="flex-shrink-0 w-4 h-4 cursor-help text-n-slate-9"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span
|
||||
v-if="webhookConfigured && !webhookUrlMismatch"
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-teal-11"
|
||||
>
|
||||
<Icon icon="i-lucide-check-circle" class="w-3.5 h-3.5" />
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.CONFIGURED_SUCCESS') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-amber-11"
|
||||
>
|
||||
<Icon icon="i-lucide-alert-triangle" class="w-3.5 h-3.5" />
|
||||
{{
|
||||
webhookUrlMismatch
|
||||
? t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.URL_MISMATCH')
|
||||
: t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.ACTION_REQUIRED')
|
||||
}}
|
||||
</span>
|
||||
<ButtonV4
|
||||
v-if="!webhookConfigured || webhookUrlMismatch"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:loading="isRegisteringWebhook"
|
||||
:disabled="isRegisteringWebhook"
|
||||
class="flex-shrink-0"
|
||||
@click="handleRegisterWebhook"
|
||||
>
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_BUTTON') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="pt-8">
|
||||
|
||||
@@ -21,7 +21,7 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
create_bot_resolved_event(conversation, reporting_event)
|
||||
reporting_event.save!
|
||||
ReportingEvents::RollupService.perform(reporting_event)
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def first_reply_created(event)
|
||||
@@ -43,7 +43,7 @@ class ReportingEventListener < BaseListener
|
||||
)
|
||||
|
||||
reporting_event.save!
|
||||
ReportingEvents::RollupService.perform(reporting_event)
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def reply_created(event)
|
||||
@@ -68,7 +68,7 @@ class ReportingEventListener < BaseListener
|
||||
event_end_time: message.created_at
|
||||
)
|
||||
reporting_event.save!
|
||||
ReportingEvents::RollupService.perform(reporting_event)
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_bot_handoff(event)
|
||||
@@ -95,7 +95,7 @@ class ReportingEventListener < BaseListener
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
reporting_event.save!
|
||||
ReportingEvents::RollupService.perform(reporting_event)
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_captain_inference_resolved(event)
|
||||
@@ -172,6 +172,16 @@ class ReportingEventListener < BaseListener
|
||||
bot_resolved_event = reporting_event.dup
|
||||
bot_resolved_event.name = 'conversation_bot_resolved'
|
||||
bot_resolved_event.save!
|
||||
ReportingEvents::RollupService.perform(bot_resolved_event)
|
||||
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
|
||||
|
||||
@@ -41,7 +41,7 @@ class Account < ApplicationRecord
|
||||
'audio_transcriptions': { 'type': %w[boolean null] },
|
||||
'auto_resolve_label': { 'type': %w[string null] },
|
||||
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
|
||||
'captain_disable_auto_resolve': { 'type': %w[boolean null] },
|
||||
'captain_auto_resolve_mode': { 'type': %w[string null], 'enum': ['evaluated', 'legacy', 'disabled', nil] },
|
||||
'conversation_required_attributes': {
|
||||
'type': %w[array null],
|
||||
'items': { 'type': 'string' }
|
||||
@@ -93,7 +93,8 @@ class Account < ApplicationRecord
|
||||
store_accessor :settings, :captain_models, :captain_features
|
||||
store_accessor :settings, :reporting_timezone
|
||||
store_accessor :settings, :keep_pending_on_bot_failure
|
||||
store_accessor :settings, :captain_disable_auto_resolve
|
||||
store_accessor :settings, :captain_auto_resolve_mode
|
||||
include AccountCaptainAutoResolve
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
module AccountCaptainAutoResolve
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
VALID_CAPTAIN_AUTO_RESOLVE_MODES = %w[evaluated legacy disabled].freeze
|
||||
|
||||
included do
|
||||
VALID_CAPTAIN_AUTO_RESOLVE_MODES.each do |mode|
|
||||
define_method("captain_auto_resolve_#{mode}?") do
|
||||
captain_auto_resolve_mode == mode
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def captain_auto_resolve_mode
|
||||
mode = settings&.[]('captain_auto_resolve_mode')
|
||||
return mode if VALID_CAPTAIN_AUTO_RESOLVE_MODES.include?(mode)
|
||||
return 'disabled' if settings&.[]('captain_disable_auto_resolve') == true
|
||||
|
||||
feature_enabled?('captain_tasks') ? 'evaluated' : 'legacy'
|
||||
end
|
||||
end
|
||||
@@ -9,9 +9,9 @@ module AutoAssignmentHandler
|
||||
private
|
||||
|
||||
def run_auto_assignment
|
||||
# Round robin kicks in on conversation create & update
|
||||
# run it only when conversation status changes to open
|
||||
return unless conversation_status_changed_to_open?
|
||||
# Assignment V2: Also trigger assignment when conversation is resolved or snoozed,
|
||||
# bypassing the open-only condition so the AssignmentJob can redistribute capacity.
|
||||
return unless conversation_status_changed_to_open? || conversation_status_changed_to_resolved_or_snoozed?
|
||||
return unless should_run_auto_assignment?
|
||||
|
||||
if inbox.auto_assignment_v2_enabled?
|
||||
@@ -25,6 +25,10 @@ module AutoAssignmentHandler
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_status_changed_to_resolved_or_snoozed?
|
||||
inbox.auto_assignment_v2_enabled? && saved_change_to_status? && (resolved? || snoozed?)
|
||||
end
|
||||
|
||||
def team_member_ids_with_capacity
|
||||
return [] if team.blank? || team.allow_auto_assign.blank?
|
||||
|
||||
@@ -33,6 +37,9 @@ module AutoAssignmentHandler
|
||||
|
||||
def should_run_auto_assignment?
|
||||
return false unless inbox.enable_auto_assignment?
|
||||
# Assignment V2: Resolved/snoozed conversations still have an assignee, so bypass the
|
||||
# assignee-blank check below. The AssignmentJob needs to run to rebalance assignments.
|
||||
return true if conversation_status_changed_to_resolved_or_snoozed?
|
||||
|
||||
# run only if assignee is blank or doesn't have access to inbox
|
||||
assignee.blank? || inbox.members.exclude?(assignee)
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ReportingEvents::BackfillService
|
||||
AGGREGATE_SELECTS = [
|
||||
:name,
|
||||
:user_id,
|
||||
:inbox_id,
|
||||
Arel.sql('COUNT(*)'),
|
||||
Arel.sql('COALESCE(SUM(value), 0)'),
|
||||
Arel.sql('COALESCE(SUM(value_in_business_hours), 0)')
|
||||
].freeze
|
||||
|
||||
DISTINCT_AGGREGATE_SELECTS = [
|
||||
:name,
|
||||
:user_id,
|
||||
:inbox_id,
|
||||
Arel.sql('COUNT(DISTINCT conversation_id)'),
|
||||
Arel.sql('COALESCE(SUM(value), 0)'),
|
||||
Arel.sql('COALESCE(SUM(value_in_business_hours), 0)')
|
||||
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
|
||||
@@ -34,10 +25,13 @@ class ReportingEvents::BackfillService
|
||||
end
|
||||
|
||||
def perform
|
||||
delete_existing_rollups
|
||||
start_utc, end_utc = date_boundaries_in_utc
|
||||
rollup_rows = build_rollup_rows(start_utc, end_utc)
|
||||
bulk_insert_rollups(rollup_rows) if rollup_rows.any?
|
||||
|
||||
ReportingEventsRollup.transaction do
|
||||
delete_existing_rollups
|
||||
bulk_insert_rollups(rollup_rows) if rollup_rows.any?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@@ -74,68 +68,31 @@ class ReportingEvents::BackfillService
|
||||
|
||||
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)
|
||||
|
||||
grouped_events(start_utc, end_utc).each { |grouped_event| accumulate_grouped_aggregates(aggregates, grouped_event) }
|
||||
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 grouped_events(start_utc, end_utc)
|
||||
standard = fetch_grouped_events(start_utc, end_utc, standard_event_names, AGGREGATE_SELECTS)
|
||||
distinct = fetch_grouped_events(start_utc, end_utc, DISTINCT_COUNT_EVENTS, DISTINCT_AGGREGATE_SELECTS)
|
||||
def aggregate_standard_events(aggregates, scope, dimension)
|
||||
group_cols, selects = dimension_groups_and_selects(dimension)
|
||||
|
||||
(standard + distinct).map { |grouped_row| grouped_event_attributes(grouped_row) }
|
||||
end
|
||||
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?
|
||||
|
||||
def standard_event_names
|
||||
ReportingEvents::EventMetricRegistry.event_names - DISTINCT_COUNT_EVENTS
|
||||
end
|
||||
|
||||
def fetch_grouped_events(start_utc, end_utc, event_names, selects)
|
||||
return [] if event_names.empty?
|
||||
|
||||
@account.reporting_events
|
||||
.where(name: event_names, created_at: start_utc...end_utc)
|
||||
.group(:name, :user_id, :inbox_id)
|
||||
.pluck(*selects)
|
||||
end
|
||||
|
||||
def dimensions(grouped_event)
|
||||
{
|
||||
'account' => @account.id,
|
||||
'agent' => grouped_event[:user_id],
|
||||
'inbox' => grouped_event[:inbox_id]
|
||||
}
|
||||
end
|
||||
|
||||
def accumulate_grouped_aggregates(aggregates, grouped_event)
|
||||
ReportingEvents::EventMetricRegistry.metrics_for_aggregate(
|
||||
grouped_event[:event_name],
|
||||
count: grouped_event[:count],
|
||||
sum_value: grouped_event[:sum_value],
|
||||
sum_value_business_hours: grouped_event[:sum_value_business_hours]
|
||||
).each do |metric, metric_data|
|
||||
accumulate_metric_aggregates(aggregates, dimensions(grouped_event), metric, metric_data)
|
||||
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 grouped_event_attributes(grouped_row)
|
||||
event_name, user_id, inbox_id, count, sum_value, sum_value_business_hours = grouped_row
|
||||
|
||||
{
|
||||
event_name: event_name,
|
||||
user_id: user_id,
|
||||
inbox_id: inbox_id,
|
||||
count: count,
|
||||
sum_value: sum_value,
|
||||
sum_value_business_hours: sum_value_business_hours
|
||||
}
|
||||
end
|
||||
|
||||
def accumulate_metric_aggregates(aggregates, dimensions, metric, metric_data)
|
||||
dimensions.each do |dimension_type, dimension_id|
|
||||
next if dimension_id.nil?
|
||||
|
||||
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
|
||||
@@ -143,6 +100,40 @@ class ReportingEvents::BackfillService
|
||||
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)
|
||||
|
||||
@@ -39,11 +39,11 @@ class Whatsapp::HealthService
|
||||
|
||||
def health_fields
|
||||
%w[
|
||||
id
|
||||
quality_rating
|
||||
messaging_limit_tier
|
||||
code_verification_status
|
||||
account_mode
|
||||
id
|
||||
display_phone_number
|
||||
name_status
|
||||
verified_name
|
||||
@@ -68,6 +68,7 @@ class Whatsapp::HealthService
|
||||
|
||||
def format_health_response(response)
|
||||
{
|
||||
id: response['id'],
|
||||
display_phone_number: response['display_phone_number'],
|
||||
verified_name: response['verified_name'],
|
||||
name_status: response['name_status'],
|
||||
@@ -75,10 +76,20 @@ class Whatsapp::HealthService
|
||||
messaging_limit_tier: response['messaging_limit_tier'],
|
||||
account_mode: response['account_mode'],
|
||||
code_verification_status: response['code_verification_status'],
|
||||
webhook_configuration: response['webhook_configuration'],
|
||||
expected_webhook_url: build_expected_webhook_url,
|
||||
throughput: response['throughput'],
|
||||
last_onboarded_time: response['last_onboarded_time'],
|
||||
platform_type: response['platform_type'],
|
||||
certificate: response['certificate'],
|
||||
business_id: @channel.provider_config['business_account_id']
|
||||
}
|
||||
end
|
||||
|
||||
def build_expected_webhook_url
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
return nil if frontend_url.blank?
|
||||
|
||||
"#{frontend_url}/webhooks/whatsapp/#{@channel.phone_number}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class Whatsapp::WebhookSetupService
|
||||
def initialize(channel, waba_id, access_token)
|
||||
def initialize(channel, waba_id = nil, access_token = nil)
|
||||
@channel = channel
|
||||
@waba_id = waba_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
@waba_id = waba_id || channel.provider_config['business_account_id']
|
||||
@access_token = access_token || channel.provider_config['api_key']
|
||||
@api_client = Whatsapp::FacebookApiClient.new(@access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
@@ -17,6 +17,11 @@ class Whatsapp::WebhookSetupService
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
def register_callback
|
||||
validate_parameters!
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
@@ -33,8 +38,6 @@ class Whatsapp::WebhookSetupService
|
||||
store_pin(pin)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
|
||||
# Continue with webhook setup even if registration fails
|
||||
# This is just a warning, not a blocking error
|
||||
end
|
||||
|
||||
def fetch_or_create_pin
|
||||
|
||||
@@ -241,6 +241,3 @@
|
||||
display_name: Advanced Assignment
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: reporting_events_rollup
|
||||
display_name: Reporting Events Rollup
|
||||
enabled: false
|
||||
|
||||
@@ -104,11 +104,11 @@ wistia:
|
||||
</div>
|
||||
|
||||
bunny:
|
||||
regex: 'https?://iframe\.mediadelivery\.net/play/(?<library_id>\d+)/(?<video_id>[^&/?]+)'
|
||||
regex: 'https?://(?:iframe|player)\.mediadelivery\.net/(?:play|embed)/(?<library_id>\d+)/(?<video_id>[^&/?]+)'
|
||||
template: |
|
||||
<div style="position: relative; padding-top: 56.25%;">
|
||||
<iframe
|
||||
src="https://iframe.mediadelivery.net/embed/%{library_id}/%{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
|
||||
src="https://player.mediadelivery.net/embed/%{library_id}/%{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
|
||||
title="Bunny video player"
|
||||
loading="lazy"
|
||||
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
|
||||
|
||||
@@ -218,6 +218,7 @@ Rails.application.routes.draw do
|
||||
delete :avatar, on: :member
|
||||
post :sync_templates, on: :member
|
||||
get :health, on: :member
|
||||
post :register_webhook, on: :member
|
||||
if ChatwootApp.enterprise?
|
||||
resource :conference, only: %i[create destroy], controller: 'conference' do
|
||||
get :token, on: :member
|
||||
|
||||
@@ -5,9 +5,9 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(inbox)
|
||||
return if inbox.account.captain_disable_auto_resolve
|
||||
return if inbox.account.captain_auto_resolve_disabled?
|
||||
|
||||
if inbox.account.feature_enabled?('captain_tasks')
|
||||
if evaluate_conversation_completion?(inbox.account)
|
||||
perform_with_evaluation(inbox)
|
||||
else
|
||||
perform_time_based(inbox)
|
||||
@@ -18,6 +18,10 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def evaluate_conversation_completion?(account)
|
||||
account.feature_enabled?('captain_tasks') && account.captain_auto_resolve_evaluated?
|
||||
end
|
||||
|
||||
def perform_time_based(inbox)
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ module Enterprise::Account::ConversationsResolutionSchedulerJob
|
||||
inbox = captain_inbox.inbox
|
||||
|
||||
next if inbox.email?
|
||||
next if inbox.account.captain_disable_auto_resolve
|
||||
next if inbox.account.captain_auto_resolve_disabled?
|
||||
|
||||
Captain::InboxPendingConversationsResolutionJob.perform_later(
|
||||
inbox
|
||||
|
||||
@@ -6,7 +6,7 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool
|
||||
conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless conversation
|
||||
return "Conversation ##{conversation.display_id} is already resolved" if conversation.resolved?
|
||||
return 'Auto-resolve is disabled for this account' if conversation.account.captain_disable_auto_resolve
|
||||
return 'Auto-resolve is disabled for this account' if conversation.account.captain_auto_resolve_disabled?
|
||||
|
||||
log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason })
|
||||
|
||||
|
||||
@@ -189,6 +189,8 @@ class ReportingEventsRollupBackfill # rubocop:disable Metrics/ClassLength
|
||||
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)
|
||||
@@ -201,13 +203,28 @@ class ReportingEventsRollupBackfill # rubocop:disable Metrics/ClassLength
|
||||
puts "Average per Day: #{(elapsed_time / days_processed).round(3)} seconds"
|
||||
puts ''
|
||||
puts 'Next steps:'
|
||||
puts "1. Enable feature flag: Account.find(#{account.id}).enable_features!('reporting_events_rollup')"
|
||||
puts '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)
|
||||
|
||||
@@ -66,15 +66,36 @@ class ReportingEventsRollupTimezoneSetup
|
||||
end
|
||||
|
||||
def find_matching_zones(offset_input)
|
||||
normalized = offset_input.gsub(/^(?!\+|-)/, '+')
|
||||
parts = normalized.split(':')
|
||||
hours = parts[0].to_i
|
||||
minutes = (parts[1] || '0').to_i
|
||||
total_seconds = (hours * 3600) + (hours.negative? ? -minutes * 60 : minutes * 60)
|
||||
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)
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@
|
||||
"json-logic-js": "^2.0.5",
|
||||
"lettersanitizer": "^1.0.6",
|
||||
"libphonenumber-js": "^1.11.9",
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it": "^14.1.1",
|
||||
"markdown-it-link-attributes": "^4.0.1",
|
||||
"md5": "^2.3.0",
|
||||
"mitt": "^3.0.1",
|
||||
|
||||
Generated
+6
-31
@@ -160,8 +160,8 @@ importers:
|
||||
specifier: ^1.11.9
|
||||
version: 1.11.9
|
||||
markdown-it:
|
||||
specifier: ^13.0.2
|
||||
version: 13.0.2
|
||||
specifier: ^14.1.1
|
||||
version: 14.1.1
|
||||
markdown-it-link-attributes:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -2206,10 +2206,6 @@ packages:
|
||||
entities@2.1.0:
|
||||
resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==}
|
||||
|
||||
entities@3.0.1:
|
||||
resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
@@ -3087,9 +3083,6 @@ packages:
|
||||
linkify-it@3.0.3:
|
||||
resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==}
|
||||
|
||||
linkify-it@4.0.1:
|
||||
resolution: {integrity: sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
@@ -3210,12 +3203,8 @@ packages:
|
||||
resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==}
|
||||
hasBin: true
|
||||
|
||||
markdown-it@13.0.2:
|
||||
resolution: {integrity: sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==}
|
||||
hasBin: true
|
||||
|
||||
markdown-it@14.1.0:
|
||||
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
|
||||
markdown-it@14.1.1:
|
||||
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
|
||||
hasBin: true
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
@@ -6841,8 +6830,6 @@ snapshots:
|
||||
|
||||
entities@2.1.0: {}
|
||||
|
||||
entities@3.0.1: {}
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
@@ -7950,10 +7937,6 @@ snapshots:
|
||||
dependencies:
|
||||
uc.micro: 1.0.6
|
||||
|
||||
linkify-it@4.0.1:
|
||||
dependencies:
|
||||
uc.micro: 1.0.6
|
||||
|
||||
linkify-it@5.0.0:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
@@ -8091,15 +8074,7 @@ snapshots:
|
||||
mdurl: 1.0.1
|
||||
uc.micro: 1.0.6
|
||||
|
||||
markdown-it@13.0.2:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 3.0.1
|
||||
linkify-it: 4.0.1
|
||||
mdurl: 1.0.1
|
||||
uc.micro: 1.0.6
|
||||
|
||||
markdown-it@14.1.0:
|
||||
markdown-it@14.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 4.5.0
|
||||
@@ -8738,7 +8713,7 @@ snapshots:
|
||||
|
||||
prosemirror-markdown@1.13.0:
|
||||
dependencies:
|
||||
markdown-it: 14.1.0
|
||||
markdown-it: 14.1.1
|
||||
prosemirror-model: 1.22.3
|
||||
|
||||
prosemirror-menu@1.2.4:
|
||||
|
||||
@@ -63,7 +63,11 @@ describe 'Markdown Embeds Configuration' do
|
||||
'bunny' => [
|
||||
{ url: 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c',
|
||||
expected: { 'library_id' => '431789', 'video_id' => '1f105841-cad9-46fe-a70e-b7623c60797c' } },
|
||||
{ url: 'https://iframe.mediadelivery.net/play/12345/abcdef-ghijkl', expected: { 'library_id' => '12345', 'video_id' => 'abcdef-ghijkl' } }
|
||||
{ url: 'https://iframe.mediadelivery.net/play/12345/abcdef-ghijkl', expected: { 'library_id' => '12345', 'video_id' => 'abcdef-ghijkl' } },
|
||||
{ url: 'https://player.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c',
|
||||
expected: { 'library_id' => '431789', 'video_id' => '1f105841-cad9-46fe-a70e-b7623c60797c' } },
|
||||
{ url: 'https://iframe.mediadelivery.net/embed/256380/d9d9ab1f-fc9f-4488-9c26-4ffc653c0024',
|
||||
expected: { 'library_id' => '256380', 'video_id' => 'd9d9ab1f-fc9f-4488-9c26-4ffc653c0024' } }
|
||||
],
|
||||
'codepen' => [
|
||||
{ url: 'https://codepen.io/username/pen/abcdef', expected: { 'user' => 'username', 'pen_id' => 'abcdef' } },
|
||||
|
||||
@@ -84,6 +84,16 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('pending')
|
||||
expect(resolvable_pending_conversation.messages.outgoing).to be_empty
|
||||
end
|
||||
|
||||
it 'falls back to legacy time-based resolve when legacy auto-resolve is forced' do
|
||||
inbox.account.update!(captain_auto_resolve_mode: 'legacy')
|
||||
allow(Captain::ConversationCompletionService).to receive(:new)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(Captain::ConversationCompletionService).not_to have_received(:new)
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM evaluation returns complete' do
|
||||
@@ -322,7 +332,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
end
|
||||
|
||||
it 'does not resolve conversations when auto-resolve is disabled at execution time' do
|
||||
inbox.account.update!(captain_disable_auto_resolve: true)
|
||||
inbox.account.update!(captain_auto_resolve_mode: 'disabled')
|
||||
|
||||
expect do
|
||||
described_class.perform_now(inbox)
|
||||
@@ -331,4 +341,14 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('pending')
|
||||
expect(resolvable_pending_conversation.messages.outgoing).to be_empty
|
||||
end
|
||||
|
||||
it 'falls back to disabled mode from legacy settings key' do
|
||||
inbox.account.update!(settings: inbox.account.settings.merge('captain_disable_auto_resolve' => true))
|
||||
|
||||
expect do
|
||||
described_class.perform_now(inbox)
|
||||
end.not_to(change { resolvable_pending_conversation.reload.status })
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('pending')
|
||||
end
|
||||
end
|
||||
|
||||
+18
-2
@@ -30,12 +30,28 @@ RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has captain_disable_auto_resolve enabled' do
|
||||
context 'when account has captain auto resolve disabled' do
|
||||
let!(:regular_inbox) { create(:inbox, account: account) }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: regular_inbox)
|
||||
account.update!(captain_disable_auto_resolve: true)
|
||||
account.update!(captain_auto_resolve_mode: 'disabled')
|
||||
end
|
||||
|
||||
it 'does not enqueue resolution jobs' do
|
||||
expect do
|
||||
described_class.perform_now
|
||||
end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob)
|
||||
.with(regular_inbox)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account uses legacy disabled settings key' do
|
||||
let!(:regular_inbox) { create(:inbox, account: account) }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: regular_inbox)
|
||||
account.update!(settings: account.settings.merge('captain_disable_auto_resolve' => true))
|
||||
end
|
||||
|
||||
it 'does not enqueue resolution jobs' do
|
||||
|
||||
@@ -41,7 +41,18 @@ RSpec.describe Captain::Tools::ResolveConversationTool do
|
||||
end
|
||||
|
||||
describe 'when auto-resolve is disabled for the account' do
|
||||
before { account.update!(captain_disable_auto_resolve: true) }
|
||||
before { account.update!(captain_auto_resolve_mode: 'disabled') }
|
||||
|
||||
it 'does not resolve and returns a disabled message' do
|
||||
result = tool.perform(tool_context, reason: 'Possible spam')
|
||||
|
||||
expect(result).to eq('Auto-resolve is disabled for this account')
|
||||
expect(conversation.reload).not_to be_resolved
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when auto-resolve is disabled via legacy settings key' do
|
||||
before { account.update!(settings: account.settings.merge('captain_disable_auto_resolve' => true)) }
|
||||
|
||||
it 'does not resolve and returns a disabled message' do
|
||||
result = tool.perform(tool_context, reason: 'Possible spam')
|
||||
|
||||
@@ -184,12 +184,12 @@ describe CustomMarkdownRenderer do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a Bunny.net URL' do
|
||||
context 'when link is a Bunny.net iframe URL' do
|
||||
let(:bunny_url) { 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
|
||||
|
||||
it 'renders an iframe with Bunny embed code' do
|
||||
output = render_markdown_link(bunny_url)
|
||||
expect(output).to include('src="https://iframe.mediadelivery.net/embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c?autoplay=false&loop=false&muted=false&preload=true&responsive=true"')
|
||||
expect(output).to include('src="https://player.mediadelivery.net/embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c?autoplay=false&loop=false&muted=false&preload=true&responsive=true"')
|
||||
expect(output).to include('allowfullscreen')
|
||||
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
|
||||
end
|
||||
@@ -200,5 +200,17 @@ describe CustomMarkdownRenderer do
|
||||
expect(output).to include('position: absolute; top: 0; height: 100%; width: 100%;')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a Bunny.net player URL' do
|
||||
let(:bunny_url) { 'https://player.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
|
||||
|
||||
it 'renders an iframe with Bunny embed code' do
|
||||
output = render_markdown_link(bunny_url)
|
||||
expect(output).to include('embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c')
|
||||
expect(output).to include('autoplay=false&loop=false&muted=false&preload=true&responsive=true')
|
||||
expect(output).to include('allowfullscreen')
|
||||
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
require 'rails_helper'
|
||||
require 'rake'
|
||||
|
||||
Rails.application.load_tasks unless Object.const_defined?(:ReportingEventsRollupBackfill)
|
||||
|
||||
describe ReportingEventsRollupBackfill do
|
||||
let(:service) { described_class.new }
|
||||
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
|
||||
let(:date) { Date.new(2026, 2, 11) }
|
||||
|
||||
describe '#execute_backfill' do
|
||||
before do
|
||||
allow(ReportingEvents::BackfillService).to receive(:backfill_date)
|
||||
allow($stdout).to receive(:flush)
|
||||
end
|
||||
|
||||
it 'prompts to enable the read path only after a successful backfill' do
|
||||
allow(service).to receive(:print_success)
|
||||
|
||||
expect(service).to receive(:prompt_enable_rollup_read_path).with(account)
|
||||
|
||||
service.send(:execute_backfill, account, date, date, 1)
|
||||
end
|
||||
|
||||
it 'does not prompt to enable the read path when backfill fails' do
|
||||
allow(ReportingEvents::BackfillService).to receive(:backfill_date).and_raise(StandardError, 'boom')
|
||||
|
||||
expect(service).not_to receive(:prompt_enable_rollup_read_path)
|
||||
|
||||
expect do
|
||||
service.send(:execute_backfill, account, date, date, 1)
|
||||
end.to raise_error(SystemExit)
|
||||
end
|
||||
|
||||
it 'does not report the backfill as failed when enabling the read path fails' do
|
||||
allow(service).to receive(:print_success)
|
||||
allow(service).to receive(:print_failure)
|
||||
allow(service).to receive(:prompt_enable_rollup_read_path).and_raise(StandardError, 'toggle failed')
|
||||
|
||||
expect do
|
||||
service.send(:execute_backfill, account, date, date, 1)
|
||||
end.to raise_error(StandardError, 'toggle failed')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#prompt_enable_rollup_read_path' do
|
||||
it 'enables the feature flag when the user confirms' do
|
||||
allow($stdin).to receive(:gets).and_return("y\n")
|
||||
|
||||
expect(account).to receive(:enable_features!).with('reporting_events_rollup')
|
||||
|
||||
service.send(:prompt_enable_rollup_read_path, account)
|
||||
end
|
||||
|
||||
it 'does not enable the feature flag when the user declines' do
|
||||
allow($stdin).to receive(:gets).and_return("n\n")
|
||||
|
||||
expect(account).not_to receive(:enable_features!)
|
||||
|
||||
service.send(:prompt_enable_rollup_read_path, account)
|
||||
end
|
||||
|
||||
it 'skips the prompt when the feature is already enabled' do
|
||||
allow(account).to receive(:feature_enabled?).with(:reporting_events_rollup).and_return(true)
|
||||
|
||||
expect($stdin).not_to receive(:gets)
|
||||
|
||||
service.send(:prompt_enable_rollup_read_path, account)
|
||||
end
|
||||
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') }
|
||||
|
||||
@@ -198,6 +198,44 @@ RSpec.describe Account do
|
||||
expect(account.settings['auto_resolve_message']).to eq(message)
|
||||
end
|
||||
|
||||
it 'defaults captain_auto_resolve_mode to legacy when captain_tasks is disabled' do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
|
||||
|
||||
expect(account.captain_auto_resolve_mode).to eq('legacy')
|
||||
expect(account).to be_captain_auto_resolve_legacy
|
||||
end
|
||||
|
||||
it 'defaults captain_auto_resolve_mode to evaluated when captain_tasks is enabled' do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
|
||||
expect(account.captain_auto_resolve_mode).to eq('evaluated')
|
||||
expect(account).to be_captain_auto_resolve_evaluated
|
||||
end
|
||||
|
||||
it 'correctly gets and sets captain_auto_resolve_mode' do
|
||||
account.captain_auto_resolve_mode = 'legacy'
|
||||
|
||||
expect(account.captain_auto_resolve_mode).to eq('legacy')
|
||||
expect(account.settings['captain_auto_resolve_mode']).to eq('legacy')
|
||||
expect(account).to be_captain_auto_resolve_legacy
|
||||
end
|
||||
|
||||
it 'allows clearing captain_auto_resolve_mode to fall back to feature defaults' do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
|
||||
account.captain_auto_resolve_mode = nil
|
||||
|
||||
expect(account).to be_valid
|
||||
expect(account.captain_auto_resolve_mode).to eq('legacy')
|
||||
expect(account.settings['captain_auto_resolve_mode']).to be_nil
|
||||
end
|
||||
|
||||
it 'falls back to disabled mode from legacy settings key' do
|
||||
account.settings = { 'captain_disable_auto_resolve' => true }
|
||||
|
||||
expect(account.captain_auto_resolve_mode).to eq('disabled')
|
||||
expect(account).to be_captain_auto_resolve_disabled
|
||||
end
|
||||
|
||||
it 'handles nil values correctly' do
|
||||
account.auto_resolve_after = nil
|
||||
account.auto_resolve_message = nil
|
||||
|
||||
@@ -9,68 +9,168 @@ describe ReportingEvents::BackfillService do
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||
|
||||
it 'treats nil metric values as zero during backfill' do
|
||||
reporting_event = create(
|
||||
:reporting_event,
|
||||
account: account,
|
||||
name: 'first_response',
|
||||
value: 100,
|
||||
value_in_business_hours: 50,
|
||||
user: user,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
created_at: Time.utc(2026, 2, 11, 15)
|
||||
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 do
|
||||
described_class.backfill_date(account, date)
|
||||
end.not_to raise_error
|
||||
|
||||
rollup = ReportingEventsRollup.find_by!(
|
||||
account_id: account.id,
|
||||
date: date,
|
||||
dimension_type: 'account',
|
||||
dimension_id: account.id,
|
||||
metric: 'first_response'
|
||||
)
|
||||
expect { 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
|
||||
|
||||
it 'aggregates grouped rows without instantiating reporting events' do
|
||||
second_user = create(:user, account: account)
|
||||
second_inbox = create(:inbox, account: account)
|
||||
second_conversation = create(:conversation, account: account, inbox: second_inbox, assignee: second_user)
|
||||
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
|
||||
|
||||
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))
|
||||
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')
|
||||
|
||||
reporting_event_instantiations = count_reporting_event_instantiations do
|
||||
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
|
||||
|
||||
expect(reporting_event_instantiations).to eq(0)
|
||||
it 'does not instantiate reporting events' do
|
||||
reporting_event_instantiations = count_reporting_event_instantiations do
|
||||
described_class.backfill_date(account, date)
|
||||
end
|
||||
|
||||
first_response_rollup = find_rollup('agent', user.id, 'first_response')
|
||||
expect(first_response_rollup.count).to eq(2)
|
||||
expect(first_response_rollup.sum_value).to eq(140)
|
||||
expect(first_response_rollup.sum_value_business_hours).to eq(80)
|
||||
expect(reporting_event_instantiations).to eq(0)
|
||||
end
|
||||
|
||||
resolution_time_rollup = find_rollup('agent', second_user.id, 'resolution_time')
|
||||
expect(resolution_time_rollup.count).to eq(1)
|
||||
expect(resolution_time_rollup.sum_value).to eq(200)
|
||||
expect(resolution_time_rollup.sum_value_business_hours).to eq(80)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user