+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
index 1ab4fa501..8448f32cf 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
@@ -6,6 +6,7 @@ import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
import AssistantEmptyStateIndex from './assistants/Index.vue';
+import AssistantOverviewIndex from './assistants/overview/Index.vue';
import AssistantSettingsIndex from './assistants/settings/Settings.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
@@ -36,6 +37,12 @@ const metaV2 = {
};
const assistantRoutes = [
+ {
+ path: frontendURL('accounts/:accountId/captain/:assistantId/overview'),
+ component: AssistantOverviewIndex,
+ name: 'captain_assistants_overview_index',
+ meta,
+ },
{
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
@@ -129,7 +136,7 @@ export const routes = [
return {
name: 'captain_assistants_index',
params: {
- navigationPath: 'captain_assistants_responses_index',
+ navigationPath: 'captain_assistants_overview_index',
...to.params,
},
};
diff --git a/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue b/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue
index 01ec64618..d366d4254 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue
@@ -53,6 +53,7 @@ const routeToLastActiveAssistant = () => {
const { navigationPath } = route.params;
const isAValidRoute = [
+ 'captain_assistants_overview_index', // Overview page
'captain_assistants_responses_index', // Faq page
'captain_assistants_documents_index', // Document page
'captain_assistants_scenarios_index', // Scenario page
@@ -64,7 +65,7 @@ const routeToLastActiveAssistant = () => {
const navigateTo = isAValidRoute
? navigationPath
- : 'captain_assistants_responses_index';
+ : 'captain_assistants_overview_index';
return routeToView(navigateTo, {
accountId: route.params.accountId,
diff --git a/app/models/message.rb b/app/models/message.rb
index f25d2e112..220bdd549 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -34,7 +34,7 @@
# index_messages_on_conversation_id (conversation_id)
# index_messages_on_created_at (created_at)
# index_messages_on_inbox_id (inbox_id)
-# index_messages_on_sender_type_and_sender_id (sender_type,sender_id)
+# index_messages_on_sender_and_created (sender_type,sender_id,created_at)
# index_messages_on_source_id (source_id)
#
diff --git a/config/routes.rb b/config/routes.rb
index e0a1a8e50..c5e7ded2f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -66,6 +66,9 @@ Rails.application.routes.draw do
resources :assistants do
member do
post :playground
+ get :stats
+ get :summary
+ get :drilldown
end
collection do
get :tools
diff --git a/db/migrate/20260630000000_add_sender_created_index_to_messages.rb b/db/migrate/20260630000000_add_sender_created_index_to_messages.rb
new file mode 100644
index 000000000..3424ead71
--- /dev/null
+++ b/db/migrate/20260630000000_add_sender_created_index_to_messages.rb
@@ -0,0 +1,21 @@
+class AddSenderCreatedIndexToMessages < ActiveRecord::Migration[7.1]
+ disable_ddl_transaction!
+
+ # Adds created_at to the (sender_type, sender_id) index so per-assistant
+ # windowed lookups (Captain Overview stats) can range-scan the time slice
+ # instead of reading every lifetime row and filtering at the heap. The new
+ # index is a left-prefix superset of the old one.
+ #
+ # TODO: drop the now-redundant index_messages_on_sender_type_and_sender_id
+ # once this index has been running in production long enough to confirm it
+ # fully replaces the old one.
+ def up
+ add_index :messages, [:sender_type, :sender_id, :created_at],
+ name: 'index_messages_on_sender_and_created', algorithm: :concurrently, if_not_exists: true
+ end
+
+ def down
+ remove_index :messages, name: 'index_messages_on_sender_and_created',
+ algorithm: :concurrently, if_exists: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 05afea9a1..1a676dd59 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
+ActiveRecord::Schema[7.1].define(version: 2026_06_30_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -1034,6 +1034,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.index ["conversation_id"], name: "index_messages_on_conversation_id"
t.index ["created_at"], name: "index_messages_on_created_at"
t.index ["inbox_id"], name: "index_messages_on_inbox_id"
+ t.index ["sender_type", "sender_id", "created_at"], name: "index_messages_on_sender_and_created"
t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id"
t.index ["source_id"], name: "index_messages_on_source_id"
end
diff --git a/enterprise/app/builders/captain/assistant_drilldown_builder.rb b/enterprise/app/builders/captain/assistant_drilldown_builder.rb
new file mode 100644
index 000000000..b98aa7620
--- /dev/null
+++ b/enterprise/app/builders/captain/assistant_drilldown_builder.rb
@@ -0,0 +1,162 @@
+# Lists the underlying records behind a single Captain assistant stat card, so a
+# viewer can drill from an aggregate (e.g. "auto-resolution 42%") into the exact
+# conversations or messages that produced it.
+#
+# The window is resolved by Captain::AssistantStatsWindow from the same `range`
+# and `timezone_offset` the stat card used, so the drilldown covers precisely the
+# rows the card counted. Records are serialized with the shared reports drilldown
+# serializer, so the existing frontend drilldown drawer/card can render them.
+class Captain::AssistantDrilldownBuilder
+ ASSISTANT_SENDER_TYPE = 'Captain::Assistant'.freeze
+ RESOLVED_EVENT_NAMES = Captain::AssistantStatsBuilder::RESOLVED_EVENT_NAMES
+ HANDOFF_EVENT_NAMES = Captain::AssistantStatsBuilder::HANDOFF_EVENT_NAMES
+
+ # Metrics whose records are individual messages rather than conversations.
+ MESSAGE_METRICS = %w[hours_saved].freeze
+ SUPPORTED_METRICS = %w[
+ conversations_handled auto_resolution_rate handoff_rate hours_saved reopen_rate conversation_depth
+ ].freeze
+
+ DEFAULT_PAGE = 1
+ DEFAULT_PER_PAGE = 25
+ MAX_PER_PAGE = 100
+
+ pattr_initialize :assistant, :params
+
+ def self.supported_metric?(metric) = SUPPORTED_METRICS.include?(metric.to_s)
+
+ def build
+ records = paginated_records.to_a
+ { meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
+ end
+
+ private
+
+ def account = assistant.account
+
+ def window
+ @window ||= Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
+ end
+
+ def range = window.current
+
+ def meta
+ {
+ metric: metric,
+ record_type: record_type,
+ current_page: current_page,
+ per_page: per_page,
+ total_count: paginated_records.total_count,
+ conversation_count: conversation_count,
+ range: { since: range.first.to_i, until: range.last.to_i }
+ }
+ end
+
+ def conversation_count
+ return paginated_records.total_count unless message_metric?
+
+ drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
+ end
+
+ def paginated_records
+ @paginated_records ||= drilldown_scope.page(current_page).per(per_page)
+ end
+
+ def drilldown_scope
+ case metric
+ when 'conversations_handled' then handled_conversations
+ when 'auto_resolution_rate' then conversations_for(resolved_events.select(:conversation_id))
+ when 'handoff_rate' then event_conversations(HANDOFF_EVENT_NAMES)
+ when 'hours_saved' then public_reply_messages
+ when 'reopen_rate' then reopened_conversations
+ when 'conversation_depth' then depth_conversations
+ else
+ raise ArgumentError, "Unsupported assistant drilldown metric: #{metric}"
+ end
+ end
+
+ # Messages the assistant authored in the window; the cohort every metric derives from.
+ def handled_messages
+ account.messages.where(sender_type: ASSISTANT_SENDER_TYPE, sender_id: assistant.id, created_at: range)
+ end
+
+ def handled_conversation_ids
+ handled_messages.select(:conversation_id)
+ end
+
+ def handled_conversations
+ conversations_for(handled_conversation_ids)
+ end
+
+ # Public agent-facing replies the assistant sent; the rows behind hours_saved.
+ def public_reply_messages
+ handled_messages.where(message_type: :outgoing, private: false)
+ .includes(:sender, conversation: [:assignee, :contact, :inbox])
+ .reorder(created_at: :desc)
+ end
+
+ # Conversations in the handled cohort that recorded one of the given reporting
+ # events in the window (resolved or handed-off).
+ def event_conversations(event_names)
+ ids = account.reporting_events
+ .where(name: event_names, created_at: range, conversation_id: handled_conversation_ids)
+ .select(:conversation_id)
+ conversations_for(ids)
+ end
+
+ # Captain resolves in the window, excluding bot-resolved rows whose conversation
+ # was also handed off, mirroring AssistantStatsBuilder#resolved_clause so the
+ # drilldown lists exactly the conversations the auto-resolution card counted.
+ def resolved_events
+ handoff_ids = account.reporting_events.where(name: HANDOFF_EVENT_NAMES, created_at: range).select(:conversation_id)
+ account.reporting_events
+ .where(name: RESOLVED_EVENT_NAMES, created_at: range, conversation_id: handled_conversation_ids)
+ .where("NOT (name = ? AND conversation_id IN (#{handoff_ids.to_sql}))",
+ Captain::AssistantStatsBuilder::BOT_RESOLVED_EVENT_NAME)
+ end
+
+ # Auto-resolved conversations that reopened at/after their Captain resolve,
+ # mirroring AssistantStatsBuilder#reopen_rate's numerator cohort.
+ def reopened_conversations
+ ids = account.reporting_events
+ .where(name: 'conversation_opened')
+ .where('reporting_events.value > 0')
+ .where('reporting_events.event_end_time <= ?', range.last)
+ .joins("INNER JOIN (#{resolved_events.to_sql}) resolves " \
+ 'ON resolves.conversation_id = reporting_events.conversation_id ' \
+ 'AND reporting_events.event_end_time >= resolves.event_end_time')
+ .select('reporting_events.conversation_id')
+ conversations_for(ids)
+ end
+
+ # Conversations the assistant sent at least one public reply in; the denominator behind conversation_depth.
+ def depth_conversations
+ conversations_for(handled_messages.where(message_type: :outgoing, private: false).select(:conversation_id))
+ end
+
+ def conversations_for(conversation_ids)
+ account.conversations
+ .where(id: conversation_ids)
+ .includes(:assignee, :contact, :inbox)
+ .order(created_at: :desc)
+ end
+
+ def record_serializer(records)
+ @record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(account, metric, false, records)
+ end
+
+ def metric = params[:metric].to_s
+
+ def message_metric? = MESSAGE_METRICS.include?(metric)
+
+ def record_type = message_metric? ? 'message' : 'conversation'
+
+ def current_page = [params[:page].to_i, DEFAULT_PAGE].max
+
+ def per_page
+ requested_per_page = params[:per_page].to_i
+ requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
+
+ [requested_per_page, MAX_PER_PAGE].min
+ end
+end
diff --git a/enterprise/app/builders/captain/assistant_stats_builder.rb b/enterprise/app/builders/captain/assistant_stats_builder.rb
new file mode 100644
index 000000000..7198c39e1
--- /dev/null
+++ b/enterprise/app/builders/captain/assistant_stats_builder.rb
@@ -0,0 +1,224 @@
+# Computes per-assistant overview metrics for the Captain Overview page.
+# Each metric is returned for the current window and the previous equal-length
+# window, plus a derived trend.
+#
+# Queries are batched to cut round trips: the message-derived counts (handled,
+# public replies, depth) and the average reply time are each computed for both
+# windows in a single scan via conditional FILTER aggregation.
+class Captain::AssistantStatsBuilder
+ RESOLVED_EVENT_NAMES = %w[conversation_captain_inference_resolved conversation_bot_resolved].freeze
+ HANDOFF_EVENT_NAMES = %w[conversation_captain_inference_handoff conversation_bot_handoff].freeze
+ BOT_RESOLVED_EVENT_NAME = 'conversation_bot_resolved'.freeze
+
+ attr_reader :assistant, :account
+
+ delegate :range, :period, to: :window
+
+ # `range` is either a day count ('7', '30', '90') or a named period
+ # ('this_month', 'last_month'). `timezone_offset` is the viewer's UTC offset in
+ # hours (as the reports API sends it), so month/day boundaries anchor to the
+ # viewer's day rather than UTC. Both windows are resolved by AssistantStatsWindow.
+ def initialize(assistant, range = Captain::AssistantStatsWindow::DEFAULT_RANGE, timezone_offset = nil)
+ @assistant = assistant
+ @account = assistant.account
+ @window = Captain::AssistantStatsWindow.new(range, timezone_offset)
+ end
+
+ def metrics
+ messages = message_window_metrics
+ reply_times = avg_reply_times
+ current = window_metrics(current_range, messages[:current], reply_times[:current])
+ previous = window_metrics(previous_range, messages[:previous], reply_times[:previous])
+
+ build_metrics(current, previous)
+ end
+
+ private
+
+ attr_reader :window
+
+ def current_range
+ window.current
+ end
+
+ def previous_range
+ window.previous
+ end
+
+ def build_metrics(current, previous)
+ {
+ conversations_handled: pack(current[:handled], previous[:handled], :percent),
+ auto_resolution_rate: pack(current[:auto_resolution], previous[:auto_resolution], :point),
+ handoff_rate: pack(current[:handoff], previous[:handoff], :point),
+ hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
+ reopen_rate: pack(current[:reopen], previous[:reopen], :point),
+ conversation_depth: pack(current[:depth], previous[:depth], :absolute),
+ knowledge: knowledge
+ }
+ end
+
+ # Combines the per-window message counts and reply time with the reporting-event metrics for one window.
+ def window_metrics(range, message_counts, avg_reply)
+ handled = message_counts[:handled]
+ public_count = message_counts[:public_count]
+ depth_conversations = message_counts[:depth_conversations]
+ resolution = resolution_counts(range)
+
+ {
+ handled: handled,
+ auto_resolution: rate(resolution[:resolved], handled),
+ handoff: rate(resolution[:handoff], handled),
+ hours_saved: (public_count * avg_reply / 3600.0).round,
+ reopen: reopen_rate(range),
+ depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
+ }
+ end
+
+ # One scan over the assistant's messages computes handled, public-reply count,
+ # and depth-conversation count for both windows via conditional aggregation.
+ def message_window_metrics
+ public_clause = "message_type = #{Message.message_types[:outgoing]} AND private = false"
+ cur = window_clause(current_range)
+ prev = window_clause(previous_range)
+
+ row = handled_scope(full_span).reorder(nil).pick(
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur})"),
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev})"),
+ Arel.sql("COUNT(*) FILTER (WHERE #{cur} AND #{public_clause})"),
+ Arel.sql("COUNT(*) FILTER (WHERE #{prev} AND #{public_clause})"),
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur} AND #{public_clause})"),
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev} AND #{public_clause})")
+ )
+
+ {
+ current: { handled: row[0], public_count: row[2], depth_conversations: row[4] },
+ previous: { handled: row[1], public_count: row[3], depth_conversations: row[5] }
+ }
+ end
+
+ # Average reply time (seconds) for both windows in one scan.
+ def avg_reply_times
+ row = account.reporting_events.where(name: 'reply_time', created_at: full_span).reorder(nil).pick(
+ Arel.sql("AVG(value) FILTER (WHERE #{window_clause(current_range)})"),
+ Arel.sql("AVG(value) FILTER (WHERE #{window_clause(previous_range)})")
+ )
+ { current: row[0].to_f, previous: row[1].to_f }
+ end
+
+ # Resolved and handed-off conversation counts for one window, in a single scan
+ # of the handled set's reporting events.
+ def resolution_counts(range)
+ row = account.reporting_events
+ .where(name: RESOLVED_EVENT_NAMES + HANDOFF_EVENT_NAMES,
+ created_at: range,
+ conversation_id: handled_scope(range).select(:conversation_id))
+ .reorder(nil)
+ .pick(
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{resolved_clause(range)})"),
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE name IN (#{quoted(HANDOFF_EVENT_NAMES)}))")
+ )
+ { resolved: row[0], handoff: row[1] }
+ end
+
+ # A countable resolve is any inference resolve, or a bot resolve on a conversation
+ # with no handoff in the window. conversation_bot_resolved fires on any resolve
+ # without an agent message (reporting_event_listener), so a handed-off conversation
+ # that goes quiet and gets closed would otherwise count as an auto-resolution too;
+ # the reports bot_resolutions metric applies the same exclusion (:exclude_bot_handoffs).
+ def resolved_clause(range)
+ "name IN (#{quoted(RESOLVED_EVENT_NAMES)}) AND #{bot_resolve_handoff_exclusion(range)}"
+ end
+
+ def bot_resolve_handoff_exclusion(range)
+ "NOT (name = #{quote(BOT_RESOLVED_EVENT_NAME)} AND conversation_id IN (#{handoff_conversation_ids(range).to_sql}))"
+ end
+
+ def handoff_conversation_ids(range)
+ account.reporting_events.where(name: HANDOFF_EVENT_NAMES, created_at: range).select(:conversation_id)
+ end
+
+ # Conversations the assistant participated in (authored any message).
+ def handled_scope(range)
+ account.messages.where(sender_type: 'Captain::Assistant', sender_id: assistant.id, created_at: range)
+ end
+
+ # Span covering both windows so a single scan can split them with FILTER.
+ def full_span
+ [current_range.first, previous_range.first].min..current_range.last
+ end
+
+ def window_clause(range)
+ "created_at >= #{quote(range.first)} AND created_at <= #{quote(range.last)}"
+ end
+
+ def quote(value)
+ account.class.connection.quote(value)
+ end
+
+ def quoted(values)
+ values.map { |value| quote(value) }.join(', ')
+ end
+
+ # Of the conversations Captain auto-resolved, the share reopened afterwards. The cohort is
+ # derived from the assistant's handled conversations (not current inbox membership) so a later
+ # inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
+ # and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
+ def reopen_rate(range)
+ resolved_scope = account.reporting_events
+ .where(name: RESOLVED_EVENT_NAMES, created_at: range,
+ conversation_id: handled_scope(range).select(:conversation_id))
+ .where(bot_resolve_handoff_exclusion(range))
+ # event_end_time on a reopen is when it actually reopened. Join it to the conversation's own
+ # Captain resolves and keep only reopens at/after one of them, so a human resolve/reopen earlier
+ # in the same window isn't mistaken for a reopen-after-Captain-resolve. (Comparing the reopen's
+ # start time instead would misfire: the inference event is dispatched just after the generic
+ # conversation_resolved that seeds event_start_time, so it can land after the reopen's start.)
+ # The reopen itself must also fall inside the window, so a completed range (last_month, the
+ # previous window) doesn't count reopens that happened after it ended.
+ reopened = account.reporting_events
+ .where(name: 'conversation_opened')
+ .where('reporting_events.value > 0')
+ .where('reporting_events.event_end_time <= ?', range.last)
+ .joins("INNER JOIN (#{resolved_scope.to_sql}) resolves " \
+ 'ON resolves.conversation_id = reporting_events.conversation_id ' \
+ 'AND reporting_events.event_end_time >= resolves.event_end_time')
+ .distinct.count('reporting_events.conversation_id')
+ rate(reopened, resolved_scope.distinct.count(:conversation_id))
+ end
+
+ # Approved/pending FAQ counts and the document total in a single round trip.
+ def knowledge
+ approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
+ Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
+ Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
+ Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
+ )
+ total = approved + pending
+
+ {
+ approved: approved,
+ pending: pending,
+ documents: documents,
+ coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
+ }
+ end
+
+ def rate(numerator, denominator)
+ return 0 if denominator.zero?
+
+ (numerator.to_f / denominator * 100).round(1)
+ end
+
+ def pack(current, previous, mode)
+ { current: current, previous: previous, trend: trend(current, previous, mode) }
+ end
+
+ def trend(current, previous, mode)
+ case mode
+ when :percent
+ previous.zero? ? 0 : ((current - previous).to_f / previous * 100).round(1)
+ else # :point and :absolute are both current - previous
+ (current - previous).round(1)
+ end
+ end
+end
diff --git a/enterprise/app/builders/captain/assistant_stats_window.rb b/enterprise/app/builders/captain/assistant_stats_window.rb
new file mode 100644
index 000000000..4495ef1e8
--- /dev/null
+++ b/enterprise/app/builders/captain/assistant_stats_window.rb
@@ -0,0 +1,78 @@
+# Resolves the current and previous comparison windows for Captain assistant
+# stats. `range` is either a day count ('7', '30', '90') or a named period
+# ('this_month', 'last_month'). The previous window mirrors the current one: the
+# preceding N days for day ranges, or the preceding month for month ranges.
+# `timezone_offset` is the viewer's UTC offset in hours (as the reports API sends
+# it), so month/day boundaries anchor to the viewer's day rather than UTC.
+#
+# Shared by Captain::AssistantStatsBuilder (which needs both windows) and
+# Captain::AssistantDrilldownBuilder (which drills into the current window), so a
+# drilldown always covers exactly the rows its stat card counted.
+class Captain::AssistantStatsWindow
+ include TimezoneHelper
+
+ DEFAULT_RANGE = '30'.freeze
+ ALLOWED_RANGES = %w[7 30 90 this_month last_month].freeze
+
+ attr_reader :range
+
+ def initialize(range = DEFAULT_RANGE, timezone_offset = nil)
+ @range = ALLOWED_RANGES.include?(range.to_s) ? range.to_s : DEFAULT_RANGE
+ @timezone = timezone_name_from_offset(timezone_offset) || Time.zone
+ end
+
+ def current
+ resolved_ranges[:current]
+ end
+
+ def previous
+ resolved_ranges[:previous]
+ end
+
+ # Human-readable description of the period the current window covers, for
+ # grounding the LLM summary in real dates.
+ def period
+ { label: period_label, starts_on: current.first.to_date, ends_on: current.last.to_date }
+ end
+
+ private
+
+ def resolved_ranges
+ @resolved_ranges ||= case range
+ when 'this_month' then this_month_ranges
+ when 'last_month' then last_month_ranges
+ else day_ranges
+ end
+ end
+
+ # Current time anchored to the viewer's timezone, so calendar boundaries land on
+ # the viewer's day instead of UTC's.
+ def now
+ @now ||= Time.current.in_time_zone(@timezone)
+ end
+
+ def this_month_ranges
+ start = now.beginning_of_month
+ elapsed = now - start
+ previous_start = start - 1.month
+ # Clamp to the previous month's end so a longer current month can't pull the
+ # comparison window into the current month and double-count its rows.
+ previous_end = [previous_start + elapsed, previous_start.end_of_month].min
+ { current: start..now, previous: previous_start..previous_end }
+ end
+
+ def last_month_ranges
+ start = (now - 1.month).beginning_of_month
+ previous_start = start - 1.month
+ { current: start..start.end_of_month, previous: previous_start..previous_start.end_of_month }
+ end
+
+ def day_ranges
+ days = range.to_i
+ { current: (now - days.days)..now, previous: (now - (2 * days).days)..(now - days.days) }
+ end
+
+ def period_label
+ { 'this_month' => 'this month', 'last_month' => 'last month' }[range] || "the last #{range.to_i} days"
+ end
+end
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
index df9dfe5cc..4fbb93d20 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
@@ -2,7 +2,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
- before_action :set_assistant, only: [:show, :update, :destroy, :playground]
+ before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
def index
@assistants = account_assistants.ordered
@@ -43,8 +43,53 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
@tools = assistant.available_agent_tools
end
+ def stats
+ render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
+ end
+
+ def summary
+ result = cached_or_generated_summary(Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]))
+
+ if result[:error]
+ render json: { error: result[:error] }, status: :unprocessable_content
+ else
+ render json: { message: result[:message] }
+ end
+ end
+
+ def drilldown
+ return head :unprocessable_entity unless Captain::AssistantDrilldownBuilder.supported_metric?(params[:metric])
+
+ render json: Captain::AssistantDrilldownBuilder.new(@assistant, drilldown_params).build
+ end
+
private
+ def drilldown_params
+ params.permit(:metric, :range, :timezone_offset, :page, :per_page)
+ end
+
+ def cached_or_generated_summary(builder)
+ cache_key = summary_cache_key(builder.range)
+ cached = Rails.cache.read(cache_key)
+ return cached if cached
+
+ result = Captain::OverviewSummaryService.new(
+ account: Current.account,
+ assistant: @assistant,
+ first_name: Current.user.name.to_s.split.first,
+ stats: builder.metrics,
+ period: builder.period
+ ).perform
+ # Don't cache transient LLM/config failures, otherwise every reload returns 422 for the next hour.
+ Rails.cache.write(cache_key, result, expires_in: 1.hour) unless result[:error]
+ result
+ end
+
+ def summary_cache_key(range)
+ "captain_overview_summary/#{@assistant.id}/#{Current.user.id}/#{range}/#{Date.current}"
+ end
+
def set_assistant
@assistant = account_assistants.find(params[:id])
end
diff --git a/enterprise/app/policies/captain/assistant_policy.rb b/enterprise/app/policies/captain/assistant_policy.rb
index bbde3ffb0..573c0400c 100644
--- a/enterprise/app/policies/captain/assistant_policy.rb
+++ b/enterprise/app/policies/captain/assistant_policy.rb
@@ -11,6 +11,14 @@ class Captain::AssistantPolicy < ApplicationPolicy
true
end
+ def summary?
+ true
+ end
+
+ def drilldown?
+ @account_user.administrator?
+ end
+
def tools?
@account_user.administrator?
end
diff --git a/lib/captain/overview_summary_service.rb b/lib/captain/overview_summary_service.rb
new file mode 100644
index 000000000..e11132d42
--- /dev/null
+++ b/lib/captain/overview_summary_service.rb
@@ -0,0 +1,83 @@
+# Generates the LLM welcome summary for the Captain Overview page from the
+# assistant's stats hash (see Captain::AssistantStatsBuilder). Renders the
+# captain_overview_summary.liquid prompt and returns markdown.
+class Captain::OverviewSummaryService < Captain::BaseTaskService
+ pattr_initialize [:account!, :assistant!, :first_name!, :stats!, :period!]
+
+ def perform
+ api_response = make_api_call(
+ feature: 'editor',
+ messages: [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: 'Write the summary.' }
+ ]
+ )
+
+ return api_response if api_response[:error]
+
+ { message: api_response[:message] }
+ end
+
+ private
+
+ def system_prompt
+ Liquid::Template.parse(prompt_from_file('captain_overview_summary')).render(prompt_variables)
+ end
+
+ def prompt_variables
+ stat_variables.merge(period_variables)
+ end
+
+ def stat_variables
+ {
+ 'first_name' => first_name.to_s,
+ 'assistant_name' => assistant.name.to_s,
+ 'conversations_handled' => current(:conversations_handled),
+ 'hours_saved' => current(:hours_saved),
+ 'auto_resolution_rate' => current(:auto_resolution_rate),
+ 'auto_resolution_trend' => trend(:auto_resolution_rate),
+ 'handoff_rate' => current(:handoff_rate),
+ 'handoff_trend' => trend(:handoff_rate),
+ 'reopen_rate' => current(:reopen_rate),
+ 'reopen_trend' => trend(:reopen_rate),
+ 'knowledge_coverage' => stats.dig(:knowledge, :coverage).to_s,
+ 'knowledge_approved' => stats.dig(:knowledge, :approved).to_s,
+ 'knowledge_documents' => stats.dig(:knowledge, :documents).to_s
+ }
+ end
+
+ def period_variables
+ {
+ 'today' => formatted_date(Time.zone.today),
+ 'period_label' => period[:label].to_s,
+ 'period_start' => formatted_date(period[:starts_on]),
+ 'period_end' => formatted_date(period[:ends_on])
+ }
+ end
+
+ def formatted_date(date)
+ date.strftime('%B %-d, %Y')
+ end
+
+ def current(key)
+ stats.dig(key, :current).to_s
+ end
+
+ def trend(key)
+ stats.dig(key, :trend).to_s
+ end
+
+ def event_name
+ 'captain_overview_summary'
+ end
+
+ def use_account_openai_hook?
+ true
+ end
+
+ # The overview summary is an internal analytics readout, not a customer-facing
+ # response, so it should not consume or be blocked by the captain_responses quota.
+ def counts_toward_usage?
+ false
+ end
+end
diff --git a/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid b/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid
new file mode 100644
index 000000000..19c32ed7c
--- /dev/null
+++ b/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid
@@ -0,0 +1,38 @@
+You are writing a short, warm summary of how an AI support assistant named "{{ assistant_name }}" performed over a reporting period, for {{ first_name }}, the person who manages it.
+
+Voice and format:
+- Address {{ first_name }} directly and open with "Hey {{ first_name }},". Be conversational, never robotic.
+- Always call the assistant by its name, {{ assistant_name }}. Never call it "Captain", "the assistant", or "your assistant".
+- This is a static, read-only poster on an analytics dashboard, not a chat. The reader cannot reply or ask you for anything. Never ask a question, invite a reply, offer further help, or say things like "let me know" or "I can dive in".
+- Write 2 to 4 sentences in one short paragraph. Add a second short paragraph only for a genuinely useful heads-up.
+- Output plain markdown only: no headings, lists, preamble, or sign-off. Do not use em dashes.
+- Never state an exact figure. This summary is cached and the live numbers keep moving, so a precise value would quickly look wrong. Round every number down to a clean approximation and soften it with words like "around", "roughly", "about", "nearly", "just over", or "upwards of". For example, render **1,248** as "upwards of **1,200**", **63.2%** as "around **60%**", and **612** hours as "roughly **600** hours". For a small count, use a loose phrase like "a handful" instead of the exact number.
+- Wrap the approximate figure in **double asterisks** so the interface can highlight it. Bold only the figures, never whole phrases or the softening word.
+
+Timing: today is {{ today }}. These stats cover {{ period_label }} ({{ period_start }} to {{ period_end }}). You may lightly reference the month, the season, or how far into the period things stand when it genuinely fits, but never invent events or facts.
+
+The stats for this period:
+
+- Conversations handled: {{ conversations_handled }}. Distinct conversations {{ assistant_name }} replied in at least once. Raw volume and adoption, not a measure of quality.
+- Hours saved: {{ hours_saved }} hours. A rough, directional estimate of agent time saved. A feel-good figure, not exact measured labor.
+- Auto-resolution rate: {{ auto_resolution_rate }}% ({{ auto_resolution_trend }} points vs previous period). Of the conversations it handled, the share {{ assistant_name }} resolved on its own with no human reply. The core performance signal; higher is better.
+- Handoff rate: {{ handoff_rate }}% ({{ handoff_trend }} points vs previous period). Of the conversations it handled, the share it escalated to a human agent. The inverse of deflection; lower is better.
+- Reopen-after-resolve rate: {{ reopen_rate }}% ({{ reopen_trend }} points vs previous period). Of the conversations it auto-resolved, the share later reopened. A quality signal; lower is better, and a high value means it closed conversations the customer was not actually done with.
+- Knowledge base: {{ knowledge_approved }} approved FAQ answers, {{ knowledge_documents }} documents, {{ knowledge_coverage }}% coverage (the share of FAQ answers the team has approved). This is setup the team controls, not something {{ assistant_name }} earned. It is a leading indicator: low coverage tends to cause low auto-resolution.
+
+
+Only the auto-resolution, handoff, and reopen rates reflect how {{ assistant_name }} actually performed, and they are the only things worth crediting it for. Conversations handled and hours saved are context. The knowledge base is an input, never a win to praise.
+
+How to judge the numbers (rough bands, do not quote them in the summary):
+- Auto-resolution rate: below 30% is low and early-stage, 30 to 50% is decent, above 50% is genuinely strong.
+- Handoff rate: above 60% is high, 30 to 60% is moderate, below 30% is strong.
+- Reopen-after-resolve rate: below 5% is healthy, 5 to 15% is worth watching, above 15% is a real problem.
+- Knowledge coverage: only worth mentioning when below 85% (below 60% is seriously thin), as a likely cause of weak auto-resolution. At 85% or above it is just the healthy baseline, so do not mention or praise it.
+- When the conversation volume is small (roughly under 30), rates are noisy, so describe them tentatively and do not over-interpret a perfect or terrible looking percentage.
+
+Writing the summary:
+- Cold start: if conversations handled is 0, there is no performance to report. Skip the auto-resolution, handoff, reopen, and hours-saved figures entirely. Instead note the knowledge base and say {{ assistant_name }} is set up and ready to start handling support (or ready to start once some knowledge is added, if the base is empty). Ignore the rest of these points in this case.
+- Be honest and proportionate. Do not call a result impressive, strong, excellent, solid, flawless, or perfect unless it clears the "strong" band above. State a low or middling number plainly or as room to grow, never dressed up. A modest summary is fine and often correct.
+- Lead with the genuinely strong results if there are any. If nothing clears the strong band, open plainly with the volume of work handled, without overselling it.
+- Mention a trend only when it is meaningful, and judge it against the bands rather than the direction alone (a rate that rose but is still in the low band is not yet a win).
+- Surface at most one proactive concern when a stat warrants it (a high handoff rate, a low auto-resolution rate, a rising reopen rate, or thin coverage). Skip it entirely when everything looks healthy. Keep it a calm observation about the data, not an alarm.
diff --git a/lib/seeders/reports/assistant_conversation_creator.rb b/lib/seeders/reports/assistant_conversation_creator.rb
new file mode 100644
index 000000000..542da1b42
--- /dev/null
+++ b/lib/seeders/reports/assistant_conversation_creator.rb
@@ -0,0 +1,203 @@
+# frozen_string_literal: true
+
+require 'faker'
+require 'active_support/testing/time_helpers'
+
+# Seeds Captain assistant activity for the reports/overview test data.
+#
+# Produces a variety of assistant-handled conversations in a single web inbox so
+# every Captain assistant overview metric (handled, auto-resolution, handoff,
+# hours saved, reopen rate, conversation depth) has realistic data:
+# - :resolved_by_assistant assistant answers and Captain auto-resolves
+# - :handled_by_both assistant answers, a human also replies and resolves
+# - :handed_off assistant answers, then hands off to a human
+# - :resolved_and_reopened Captain resolves, then the conversation reopens
+#
+# Reporting events are fired through ReportingEventListener directly (mirroring
+# ConversationCreator) so the same rows the builder reads from get populated.
+class Seeders::Reports::AssistantConversationCreator
+ include ActiveSupport::Testing::TimeHelpers
+
+ OUTCOMES = %i[resolved_by_assistant handled_by_both handed_off resolved_and_reopened].freeze
+
+ def initialize(account:, assistant:, inbox:, resources:)
+ @account = account
+ @assistant = assistant
+ @inbox = inbox
+ @contacts = resources[:contacts]
+ @agents = inbox.members.to_a.presence || resources[:agents]
+ end
+
+ def create_conversation(created_at:, outcome:)
+ conversation = nil
+
+ travel_to(created_at) do
+ conversation = build_conversation
+ conversation.save!
+ seed_dialogue(conversation, outcome)
+ end
+ travel_back
+
+ apply_outcome(conversation, created_at, outcome)
+ conversation
+ end
+
+ private
+
+ def build_conversation
+ contact = @contacts.sample
+ contact_inbox = @inbox.contact_inboxes.find_or_create_by!(contact: contact, source_id: SecureRandom.hex)
+
+ contact_inbox.conversations.create!(
+ account: @account,
+ inbox: @inbox,
+ contact: contact,
+ priority: [nil, 'high', 'medium', 'low'].sample
+ )
+ end
+
+ # Builds the message exchange for the conversation while time is frozen at its
+ # creation moment. Every outcome starts with a customer question and at least
+ # one public assistant reply so the conversation lands in the assistant's
+ # handled set; some outcomes add a human reply or a handoff.
+ def seed_dialogue(conversation, outcome)
+ customer_message = incoming_message(conversation)
+
+ travel(rand((20.seconds)..(5.minutes)))
+ assistant_reply(conversation, waiting_since: customer_message.created_at)
+
+ case outcome
+ when :handed_off then seed_handoff(conversation)
+ when :handled_by_both then seed_human_turn(conversation)
+ else seed_assistant_follow_up(conversation)
+ end
+ end
+
+ def seed_handoff(conversation)
+ travel(rand((1.minute)..(10.minutes)))
+ handoff_to_human(conversation)
+ travel(rand((1.minute)..(15.minutes)))
+ human_reply(conversation)
+ end
+
+ def seed_human_turn(conversation)
+ travel(rand((1.minute)..(15.minutes)))
+ human_reply(conversation)
+ end
+
+ # Pure assistant threads occasionally take a second turn, giving depth > 1.
+ def seed_assistant_follow_up(conversation)
+ return unless rand < 0.6
+
+ travel(rand((1.minute)..(10.minutes)))
+ incoming_message(conversation)
+ travel(rand((20.seconds)..(5.minutes)))
+ assistant_reply(conversation, waiting_since: Time.current)
+ end
+
+ def apply_outcome(conversation, created_at, outcome)
+ resolved_at = created_at + rand((30.minutes)..(8.hours))
+
+ case outcome
+ when :resolved_by_assistant
+ resolve_by_captain(conversation, resolved_at)
+ when :handled_by_both
+ resolve_by_human(conversation, resolved_at)
+ when :handed_off
+ resolve_by_human(conversation, resolved_at) if rand < 0.6
+ when :resolved_and_reopened
+ resolve_by_captain(conversation, resolved_at)
+ reopen(conversation, resolved_at + rand((1.hour)..(24.hours)))
+ end
+ end
+
+ def incoming_message(conversation)
+ conversation.messages.create!(
+ account: @account,
+ inbox: @inbox,
+ message_type: :incoming,
+ content: Faker::Lorem.paragraph(sentence_count: rand(1..3)),
+ sender: conversation.contact
+ )
+ end
+
+ def assistant_reply(conversation, waiting_since:)
+ message = conversation.messages.create!(
+ account: @account,
+ inbox: @inbox,
+ message_type: :outgoing,
+ private: false,
+ content: Faker::Lorem.paragraph(sentence_count: rand(1..4)),
+ sender: @assistant
+ )
+ trigger_reply_time(message, waiting_since)
+ message
+ end
+
+ def human_reply(conversation)
+ agent = @agents.sample
+ conversation.update_column(:assignee_id, agent.id) if conversation.assignee_id.nil? # rubocop:disable Rails/SkipsModelValidations
+
+ conversation.messages.create!(
+ account: @account,
+ inbox: @inbox,
+ message_type: :outgoing,
+ private: false,
+ content: Faker::Lorem.paragraph(sentence_count: rand(1..4)),
+ sender: agent
+ )
+ end
+
+ def resolve_by_captain(conversation, resolved_at)
+ mark_resolved(conversation, resolved_at)
+ travel_to(resolved_at) do
+ trigger_event('conversation_resolved', conversation)
+ trigger_event('conversation_captain_inference_resolved', conversation)
+ end
+ travel_back
+ end
+
+ def resolve_by_human(conversation, resolved_at)
+ mark_resolved(conversation, resolved_at)
+ travel_to(resolved_at) do
+ trigger_event('conversation_resolved', conversation)
+ end
+ travel_back
+ end
+
+ def reopen(conversation, reopened_at)
+ # rubocop:disable Rails/SkipsModelValidations
+ conversation.update_column(:status, :open)
+ conversation.update_column(:updated_at, reopened_at)
+ # rubocop:enable Rails/SkipsModelValidations
+
+ travel_to(reopened_at) do
+ trigger_event('conversation_opened', conversation)
+ end
+ travel_back
+ end
+
+ def handoff_to_human(conversation)
+ trigger_event('conversation_captain_inference_handoff', conversation)
+ end
+
+ def mark_resolved(conversation, resolved_at)
+ # rubocop:disable Rails/SkipsModelValidations
+ conversation.update_column(:status, :resolved)
+ conversation.update_column(:updated_at, resolved_at)
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+
+ def trigger_event(name, conversation)
+ ReportingEventListener.instance.public_send(
+ name, Events::Base.new(name, Time.current, { conversation: conversation })
+ )
+ end
+
+ def trigger_reply_time(message, waiting_since)
+ ReportingEventListener.instance.reply_created(
+ Events::Base.new('reply_created', Time.current,
+ { message: message, conversation: message.conversation, waiting_since: waiting_since })
+ )
+ end
+end
diff --git a/lib/seeders/reports/report_data_seeder.rb b/lib/seeders/reports/report_data_seeder.rb
index 909818b72..fecb02deb 100644
--- a/lib/seeders/reports/report_data_seeder.rb
+++ b/lib/seeders/reports/report_data_seeder.rb
@@ -17,6 +17,9 @@
# - 5 teams with realistic distribution
# - 30 labels with random assignments
# - 3 inboxes with agent assignments
+# - 1 Captain assistant bound to a single web inbox, with knowledge (FAQs + documents)
+# and a variety of assistant-handled conversations (auto-resolved, handed off,
+# handled with a human, resolved-then-reopened) for the assistant overview page
# - Realistic reporting events with historical timestamps
#
# Note: This seeder clears existing data for the account before seeding.
@@ -24,8 +27,9 @@
require 'faker'
require_relative 'conversation_creator'
require_relative 'message_creator'
+require_relative 'assistant_conversation_creator'
-# rubocop:disable Rails/Output
+# rubocop:disable Rails/Output, Metrics/ClassLength
class Seeders::Reports::ReportDataSeeder
include ActiveSupport::Testing::TimeHelpers
@@ -36,6 +40,11 @@ class Seeders::Reports::ReportDataSeeder
TOTAL_LABELS = 30
TOTAL_INBOXES = 3
MESSAGES_PER_CONVERSATION = 5
+ # Captain assistant conversations, split across the outcomes the overview page reports on.
+ TOTAL_ASSISTANT_CONVERSATIONS = 120
+ ASSISTANT_KNOWLEDGE_APPROVED = 14
+ ASSISTANT_KNOWLEDGE_PENDING = 6
+ ASSISTANT_DOCUMENTS = 4
START_DATE = 3.months.ago # rubocop:disable Rails/RelativeDateConstant
END_DATE = Time.current
@@ -48,6 +57,8 @@ class Seeders::Reports::ReportDataSeeder
@labels = []
@inboxes = []
@contacts = []
+ @assistant = nil
+ @assistant_inbox = nil
end
def perform!
@@ -61,8 +72,10 @@ class Seeders::Reports::ReportDataSeeder
create_labels
create_inboxes
create_contacts
+ create_assistant
create_conversations
+ create_assistant_conversations
puts "Completed reports data seeding for account: #{@account.name}"
end
@@ -71,6 +84,7 @@ class Seeders::Reports::ReportDataSeeder
def clear_existing_data
puts "Clearing existing data for account: #{@account.id}"
+ clear_assistant_data
@account.teams.destroy_all
@account.conversations.destroy_all
@account.labels.destroy_all
@@ -80,6 +94,16 @@ class Seeders::Reports::ReportDataSeeder
@account.reporting_events.destroy_all
end
+ # Delete Captain records directly (assistant associations are destroy_async, which
+ # would leave rows around mid-reseed); order respects foreign keys.
+ def clear_assistant_data
+ assistant_ids = Captain::Assistant.for_account(@account.id).select(:id)
+ Captain::AssistantResponse.by_account(@account.id).delete_all
+ Captain::Document.for_account(@account.id).delete_all
+ CaptainInbox.where(captain_assistant_id: assistant_ids).delete_all
+ Captain::Assistant.for_account(@account.id).delete_all
+ end
+
def create_teams
TOTAL_TEAMS.times do |i|
team = @account.teams.create!(
@@ -208,6 +232,80 @@ class Seeders::Reports::ReportDataSeeder
print "\n"
end
+ # One assistant, bound to a single web inbox (the first one), as the overview page expects.
+ def create_assistant
+ @account.enable_features!('captain_integration', 'captain_integration_v2')
+ @assistant_inbox = @inboxes.first
+ @assistant = Captain::Assistant.create!(
+ account: @account,
+ name: "#{Faker::Company.name} Copilot",
+ description: 'Captain assistant handling website support conversations.',
+ config: { feature_faq: true, feature_memory: true, product_name: @account.name }
+ )
+ CaptainInbox.create!(captain_assistant: @assistant, inbox: @assistant_inbox)
+ create_assistant_knowledge
+
+ puts "Created assistant '#{@assistant.name}' for inbox '#{@assistant_inbox.name}'"
+ end
+
+ def create_assistant_knowledge
+ ASSISTANT_KNOWLEDGE_APPROVED.times { create_assistant_response(:approved) }
+ ASSISTANT_KNOWLEDGE_PENDING.times { create_assistant_response(:pending) }
+
+ ASSISTANT_DOCUMENTS.times do
+ Captain::Document.create!(
+ account: @account,
+ assistant: @assistant,
+ name: Faker::Company.catch_phrase,
+ external_link: "https://#{Faker::Internet.domain_name}/#{Faker::Internet.slug}",
+ content: Faker::Lorem.paragraphs(number: rand(2..4)).join("\n\n"),
+ status: :available,
+ sync_status: :synced
+ )
+ end
+ end
+
+ def create_assistant_response(status)
+ Captain::AssistantResponse.create!(
+ account: @account,
+ assistant: @assistant,
+ question: "#{Faker::Lorem.sentence(word_count: rand(4..8)).chomp('.')}?",
+ answer: Faker::Lorem.paragraph(sentence_count: rand(2..4)),
+ status: status
+ )
+ end
+
+ def create_assistant_conversations
+ creator = Seeders::Reports::AssistantConversationCreator.new(
+ account: @account,
+ assistant: @assistant,
+ inbox: @assistant_inbox,
+ resources: { contacts: @contacts, agents: @agents }
+ )
+
+ outcomes = assistant_outcome_distribution
+ outcomes.each_with_index do |outcome, i|
+ created_at = Faker::Time.between(from: 65.days.ago, to: END_DATE)
+ creator.create_conversation(created_at: created_at, outcome: outcome)
+
+ print "\rCreating assistant conversations: #{i + 1}/#{outcomes.size}"
+ end
+
+ print "\n"
+ end
+
+ # Weighted mix of outcomes so every overview metric has meaningful numbers, shuffled
+ # so they interleave across the time span rather than clustering by type.
+ def assistant_outcome_distribution
+ counts = {
+ resolved_by_assistant: (TOTAL_ASSISTANT_CONVERSATIONS * 0.4).round,
+ handled_by_both: (TOTAL_ASSISTANT_CONVERSATIONS * 0.25).round,
+ handed_off: (TOTAL_ASSISTANT_CONVERSATIONS * 0.2).round,
+ resolved_and_reopened: (TOTAL_ASSISTANT_CONVERSATIONS * 0.15).round
+ }
+ counts.flat_map { |outcome, count| [outcome] * count }.shuffle
+ end
+
def create_conversations
conversation_creator = Seeders::Reports::ConversationCreator.new(
account: @account,
@@ -231,4 +329,4 @@ class Seeders::Reports::ReportDataSeeder
print "\n"
end
end
-# rubocop:enable Rails/Output
+# rubocop:enable Rails/Output, Metrics/ClassLength
diff --git a/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb
new file mode 100644
index 000000000..6575c9856
--- /dev/null
+++ b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb
@@ -0,0 +1,271 @@
+require 'rails_helper'
+
+RSpec.describe Captain::AssistantStatsBuilder do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:inbox) { create(:inbox, account: account) }
+
+ before { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
+
+ describe '#metrics' do
+ # Two conversations handled in the current 30-day window, one in the previous.
+ let(:current_convo_a) { create(:conversation, account: account, inbox: inbox) }
+ let(:current_convo_b) { create(:conversation, account: account, inbox: inbox) }
+ let(:previous_convo) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ [current_convo_a, current_convo_b].each do |conversation|
+ create(:message, account: account, inbox: inbox, conversation: conversation,
+ sender: assistant, message_type: :outgoing, private: false, created_at: 5.days.ago)
+ end
+ create(:message, account: account, inbox: inbox, conversation: previous_convo,
+ sender: assistant, message_type: :outgoing, private: false, created_at: 45.days.ago)
+ end
+
+ it 'returns every metric for the current and previous window' do
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics.keys).to contain_exactly(
+ :conversations_handled, :auto_resolution_rate, :handoff_rate,
+ :hours_saved, :reopen_rate, :conversation_depth, :knowledge
+ )
+ expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
+ end
+
+ it 'counts distinct handled conversations per window and the percent trend' do
+ handled = described_class.new(assistant, '30').metrics[:conversations_handled]
+
+ expect(handled[:current]).to eq(2)
+ expect(handled[:previous]).to eq(1)
+ expect(handled[:trend]).to eq(100.0)
+ end
+
+ it 'derives auto-resolution and handoff rates from reporting events on the handled set' do
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_captain_inference_resolved')
+ create(:reporting_event, account: account, conversation: current_convo_b,
+ name: 'conversation_captain_inference_handoff')
+
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
+ expect(metrics[:handoff_rate][:current]).to eq(50.0)
+ end
+
+ it 'does not count a bot resolve as an auto-resolution when the conversation was handed off' do
+ # convo_a: handoff, customer goes quiet, resolve lands without an agent message, so the
+ # listener still emits conversation_bot_resolved for the handed-off conversation. It must
+ # not count as an auto-resolution, but still counts as a handoff.
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_bot_handoff')
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_bot_resolved')
+ # convo_b: a clean bot resolve with no handoff still counts, so the exclusion is scoped
+ # to handed-off conversations and doesn't drop every bot resolve.
+ create(:reporting_event, account: account, conversation: current_convo_b,
+ name: 'conversation_bot_resolved')
+
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
+ expect(metrics[:handoff_rate][:current]).to eq(50.0)
+ end
+
+ it 'still counts an inference resolve when the conversation was also handed off' do
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_captain_inference_handoff')
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_captain_inference_resolved')
+
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
+ expect(metrics[:handoff_rate][:current]).to eq(50.0)
+ end
+
+ it 'excludes resolution events that fall outside the current window' do
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_captain_inference_resolved', created_at: 60.days.ago)
+
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics[:auto_resolution_rate][:current]).to eq(0.0)
+ end
+
+ it 'computes conversation depth as public replies per handled conversation' do
+ depth = described_class.new(assistant, '30').metrics[:conversation_depth]
+
+ # 2 public outgoing replies across 2 distinct conversations in the current window.
+ expect(depth[:current]).to eq(1.0)
+ end
+
+ it 'ignores private notes and incoming messages when counting public replies' do
+ create(:message, account: account, inbox: inbox, conversation: current_convo_a,
+ sender: assistant, message_type: :outgoing, private: true, created_at: 5.days.ago)
+
+ depth = described_class.new(assistant, '30').metrics[:conversation_depth]
+
+ expect(depth[:current]).to eq(1.0)
+ end
+ end
+
+ describe 'range handling' do
+ it 'accepts the allowed day and named ranges' do
+ %w[7 30 90 this_month last_month].each do |allowed|
+ expect(described_class.new(assistant, allowed).range).to eq(allowed)
+ end
+ end
+
+ it 'falls back to the default range for values outside the allowed set' do
+ expect(described_class.new(assistant, '365000').range).to eq('30')
+ expect(described_class.new(assistant, 'bogus').range).to eq('30')
+ expect(described_class.new(assistant, nil).range).to eq('30')
+ end
+ end
+
+ describe '#metrics reopen_rate' do
+ # A conversation the assistant handled (messaged) inside the current 30-day window.
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ create(:message, account: account, inbox: inbox, conversation: conversation,
+ sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
+ end
+
+ it 'counts a reopen that happened after the captain resolve' do
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
+ end
+
+ it 'ignores a human resolve/reopen that happened before the captain resolve' do
+ # Earlier resolve/reopen cycle, then Captain resolves later in the same window.
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 20.days.ago, event_end_time: 18.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_bot_resolved', event_start_time: 5.days.ago, event_end_time: 5.days.ago)
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(0.0)
+ end
+
+ it 'counts an evaluated-path reopen when bot_resolved is skipped and the inference event is newer' do
+ # Prior human reply => create_bot_resolved_event skips conversation_bot_resolved, so the cohort
+ # only holds the inference event, which is dispatched a moment after the generic conversation_resolved
+ # that seeds the reopen's event_start_time. The match must use the reopen's actual reopen time.
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_captain_inference_resolved',
+ event_start_time: 6.days.ago, event_end_time: 6.days.ago + 1.second)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 3.days.ago)
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
+ end
+
+ it 'counts both inference and time-based bot resolves in the denominator' do
+ # conversation: inference-resolved and reopened
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
+ # other: time-based bot-resolved, never reopened
+ other = create(:conversation, account: account, inbox: inbox)
+ create(:message, account: account, inbox: inbox, conversation: other,
+ sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: other,
+ name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(50.0)
+ end
+
+ it 'ignores a reopen that landed after a completed window ended' do
+ travel_to(Time.utc(2026, 7, 15)) do
+ convo = create(:conversation, account: account, inbox: inbox)
+ create(:message, account: account, inbox: inbox, conversation: convo,
+ sender: assistant, message_type: :outgoing, private: false, created_at: Time.utc(2026, 6, 10))
+ create(:reporting_event, account: account, inbox: inbox, conversation: convo,
+ name: 'conversation_bot_resolved', created_at: Time.utc(2026, 6, 12),
+ event_start_time: Time.utc(2026, 6, 12), event_end_time: Time.utc(2026, 6, 12))
+ # Reopened on July 1, after the June window closed; June's rate must not count it.
+ create(:reporting_event, account: account, inbox: inbox, conversation: convo,
+ name: 'conversation_opened', value: 120,
+ event_start_time: Time.utc(2026, 6, 12), event_end_time: Time.utc(2026, 7, 1))
+
+ expect(described_class.new(assistant, 'last_month').metrics[:reopen_rate][:current]).to eq(0.0)
+ end
+ end
+
+ it 'derives the cohort from handled conversations, not current inbox membership' do
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
+ # The assistant is later removed from the inbox; the cohort must still resolve via handled messages.
+ CaptainInbox.where(captain_assistant: assistant).delete_all
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
+ end
+ end
+
+ describe 'timezone anchoring' do
+ # 2026-07-01 03:00 UTC is still 2026-06-30 in any timezone behind UTC by 4h+.
+ it 'anchors the this_month window to the supplied offset, not UTC' do
+ travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
+ utc = described_class.new(assistant, 'this_month').period
+ la = described_class.new(assistant, 'this_month', -7).period
+
+ expect(utc[:starts_on]).to eq(Date.new(2026, 7, 1))
+ expect(la[:starts_on]).to eq(Date.new(2026, 6, 1))
+ expect(la[:ends_on]).to eq(Date.new(2026, 6, 30))
+ end
+ end
+
+ it 'defaults to UTC when no offset is given' do
+ travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
+ expect(described_class.new(assistant, 'this_month').period[:starts_on]).to eq(Date.new(2026, 7, 1))
+ end
+ end
+ end
+
+ describe '#metrics knowledge' do
+ before do
+ create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
+ create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
+ create_list(:captain_document, 2, assistant: assistant, account: account)
+ end
+
+ it 'returns approved, pending, document counts and coverage' do
+ knowledge = described_class.new(assistant, '30').metrics[:knowledge]
+
+ expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
+ end
+
+ it 'reports zero coverage when there are no responses' do
+ Captain::AssistantResponse.where(assistant: assistant).delete_all
+
+ knowledge = described_class.new(assistant, '30').metrics[:knowledge]
+
+ expect(knowledge[:coverage]).to eq(0)
+ end
+ end
+
+ describe '#period' do
+ it 'labels a day range and exposes its bounds' do
+ period = described_class.new(assistant, '30').period
+
+ expect(period[:label]).to eq('the last 30 days')
+ expect(period[:starts_on]).to eq(30.days.ago.to_date)
+ expect(period[:ends_on]).to eq(Time.zone.today)
+ end
+
+ it 'labels the this_month range' do
+ expect(described_class.new(assistant, 'this_month').period[:label]).to eq('this month')
+ end
+
+ it 'labels the last_month range' do
+ expect(described_class.new(assistant, 'last_month').period[:label]).to eq('last month')
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
index 4689defaf..afb6aa2de 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
@@ -252,6 +252,48 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
end
end
+ describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}/summary' do
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:alice) { create(:user, account: account, role: :administrator, name: 'Alice Adams') }
+ let(:bob) { create(:user, account: account, role: :administrator, name: 'Bob Brown') }
+ let(:summary_service) { instance_double(Captain::OverviewSummaryService) }
+
+ def get_summary(user)
+ get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/summary",
+ params: { range: '30' },
+ headers: user.create_new_auth_token,
+ as: :json
+ end
+
+ before do
+ # Test env uses a null store; swap in a real store so caching behaviour is observable.
+ allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
+ allow(Captain::OverviewSummaryService).to receive(:new).and_return(summary_service)
+ end
+
+ it 'caches the summary per viewer so one user never receives another user\'s greeting' do
+ allow(summary_service).to receive(:perform).and_return({ message: 'Hi Alice' })
+
+ get_summary(alice)
+ get_summary(alice) # served from Alice's cache, no regeneration
+ get_summary(bob) # distinct cache key, regenerated for Bob
+
+ expect(response).to have_http_status(:success)
+ expect(Captain::OverviewSummaryService).to have_received(:new).twice
+ end
+
+ it 'does not cache failures so a transient error is retried' do
+ allow(summary_service).to receive(:perform).and_return({ error: 'LLM unavailable' })
+
+ get_summary(alice)
+ get_summary(alice)
+
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(json_response[:error]).to eq('LLM unavailable')
+ expect(Captain::OverviewSummaryService).to have_received(:new).twice
+ end
+ end
+
describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{id}/playground' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:valid_params) do
diff --git a/spec/enterprise/policies/captain/assistant_policy_spec.rb b/spec/enterprise/policies/captain/assistant_policy_spec.rb
index e3c62846f..e04b680e4 100644
--- a/spec/enterprise/policies/captain/assistant_policy_spec.rb
+++ b/spec/enterprise/policies/captain/assistant_policy_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do
end
end
- permissions :tools?, :create?, :update?, :destroy?, :sync? do
+ permissions :tools?, :create?, :update?, :destroy?, :sync?, :drilldown? do
context 'when administrator' do
it { expect(assistant_policy).to permit(administrator_context, assistant) }
end
From 29d0b92f1c8b00b74686d89d389405185bdda5d0 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Tue, 7 Jul 2026 19:16:24 +0530
Subject: [PATCH 09/12] fix: captain hours saved metric (#14948)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reworks the Captain assistant "hours saved" metric so it produces a
believable number. It previously multiplied assistant reply count by
customer reply-wait time, which inflated the figure into the millions of
hours. It now estimates saved time as replies × a fixed ~2 min per-reply
effort assumption. On the overview card the value renders in days once
it passes 100 hours, and the label/hint copy was updated to match.
## How to test
1. Open the Captain assistant overview page.
2. Check the "Time saved" card shows a sensible value (hours, or days
past 100h).
3. Hover the hint and confirm it reflects the per-reply estimate.
---
.../i18n/locale/en/integrations.json | 4 +--
.../captain/assistants/overview/Index.vue | 9 ++++--
.../captain/assistant_stats_builder.rb | 29 ++++++++-----------
.../reports/assistant_conversation_creator.rb | 4 +--
4 files changed, 23 insertions(+), 23 deletions(-)
diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json
index a39165268..a6f3ef2f2 100644
--- a/app/javascript/dashboard/i18n/locale/en/integrations.json
+++ b/app/javascript/dashboard/i18n/locale/en/integrations.json
@@ -427,8 +427,8 @@
"HINT": "Share of handled conversations escalated to a human agent."
},
"HOURS_SAVED": {
- "LABEL": "Hours saved",
- "HINT": "Estimate: Captain replies times the team's average response time. Directional, not measured labor."
+ "LABEL": "Time saved",
+ "HINT": "Estimate: Captain replies times ~2 minutes of assumed agent effort per reply. Directional, not measured labor."
},
"REOPEN": {
"LABEL": "Reopen rate",
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
index 687e97286..b9cc6ae35 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
@@ -49,6 +49,11 @@ const resolveTrendGood = (trendValue, direction) => {
// :point, and a plain number for :absolute counts like conversation depth.
const TREND_SUFFIX = { percent: '%', point: ' pts', absolute: '' };
+// Hours-saved is reported in hours, but large values read better as days. Past
+// 100h we switch the unit so the card stays legible.
+const formatDuration = hours =>
+ hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
+
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const data = stats.value?.[statKey];
if (!data) return { value: '—', trend: '', trendGood: null };
@@ -84,7 +89,7 @@ const metrics = computed(() => [
key: 'hoursSaved',
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
- ...metricFor('hours_saved', v => `${v}h`, 'up'),
+ ...metricFor('hours_saved', formatDuration, 'up'),
},
{
key: 'reopen',
@@ -121,7 +126,7 @@ const metrics = computed(() => [
-
+
diff --git a/enterprise/app/builders/captain/assistant_stats_builder.rb b/enterprise/app/builders/captain/assistant_stats_builder.rb
index 7198c39e1..d162406ad 100644
--- a/enterprise/app/builders/captain/assistant_stats_builder.rb
+++ b/enterprise/app/builders/captain/assistant_stats_builder.rb
@@ -3,13 +3,18 @@
# window, plus a derived trend.
#
# Queries are batched to cut round trips: the message-derived counts (handled,
-# public replies, depth) and the average reply time are each computed for both
-# windows in a single scan via conditional FILTER aggregation.
+# public replies, depth) are computed for both windows in a single scan via
+# conditional FILTER aggregation.
class Captain::AssistantStatsBuilder
RESOLVED_EVENT_NAMES = %w[conversation_captain_inference_resolved conversation_bot_resolved].freeze
HANDOFF_EVENT_NAMES = %w[conversation_captain_inference_handoff conversation_bot_handoff].freeze
BOT_RESOLVED_EVENT_NAME = 'conversation_bot_resolved'.freeze
+ # Assumed agent effort displaced by each public assistant reply. Reporting data
+ # only captures reply latency (customer wait time), not handling effort, so hours
+ # saved is a count-times-assumed-effort estimate rather than a measured duration.
+ SECONDS_SAVED_PER_REPLY = 2.minutes.to_i
+
attr_reader :assistant, :account
delegate :range, :period, to: :window
@@ -26,9 +31,8 @@ class Captain::AssistantStatsBuilder
def metrics
messages = message_window_metrics
- reply_times = avg_reply_times
- current = window_metrics(current_range, messages[:current], reply_times[:current])
- previous = window_metrics(previous_range, messages[:previous], reply_times[:previous])
+ current = window_metrics(current_range, messages[:current])
+ previous = window_metrics(previous_range, messages[:previous])
build_metrics(current, previous)
end
@@ -57,8 +61,8 @@ class Captain::AssistantStatsBuilder
}
end
- # Combines the per-window message counts and reply time with the reporting-event metrics for one window.
- def window_metrics(range, message_counts, avg_reply)
+ # Combines the per-window message counts with the reporting-event metrics for one window.
+ def window_metrics(range, message_counts)
handled = message_counts[:handled]
public_count = message_counts[:public_count]
depth_conversations = message_counts[:depth_conversations]
@@ -68,7 +72,7 @@ class Captain::AssistantStatsBuilder
handled: handled,
auto_resolution: rate(resolution[:resolved], handled),
handoff: rate(resolution[:handoff], handled),
- hours_saved: (public_count * avg_reply / 3600.0).round,
+ hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
reopen: reopen_rate(range),
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
}
@@ -96,15 +100,6 @@ class Captain::AssistantStatsBuilder
}
end
- # Average reply time (seconds) for both windows in one scan.
- def avg_reply_times
- row = account.reporting_events.where(name: 'reply_time', created_at: full_span).reorder(nil).pick(
- Arel.sql("AVG(value) FILTER (WHERE #{window_clause(current_range)})"),
- Arel.sql("AVG(value) FILTER (WHERE #{window_clause(previous_range)})")
- )
- { current: row[0].to_f, previous: row[1].to_f }
- end
-
# Resolved and handed-off conversation counts for one window, in a single scan
# of the handled set's reporting events.
def resolution_counts(range)
diff --git a/lib/seeders/reports/assistant_conversation_creator.rb b/lib/seeders/reports/assistant_conversation_creator.rb
index 542da1b42..9462d899e 100644
--- a/lib/seeders/reports/assistant_conversation_creator.rb
+++ b/lib/seeders/reports/assistant_conversation_creator.rb
@@ -90,9 +90,9 @@ class Seeders::Reports::AssistantConversationCreator
return unless rand < 0.6
travel(rand((1.minute)..(10.minutes)))
- incoming_message(conversation)
+ follow_up = incoming_message(conversation)
travel(rand((20.seconds)..(5.minutes)))
- assistant_reply(conversation, waiting_since: Time.current)
+ assistant_reply(conversation, waiting_since: follow_up.created_at)
end
def apply_outcome(conversation, created_at, outcome)
From ce8c8e9a11e5e5684f060f9cb858aa71728ea568 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Wed, 8 Jul 2026 05:04:24 +0400
Subject: [PATCH 10/12] fix: sanitize control characters in team names
(#14865)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Team names created via the API could contain control characters (for
example a trailing newline). Because the team-delete confirmation dialog
requires you to retype the team name and matches it against the stored
value, a hidden control character meant the typed name never matched —
leaving the team impossible to delete from the UI. This sanitizes team
names on save so they stay clean and deletable.
#### How to reproduce
1. Create a team via `POST /api/v1/accounts/{account_id}/teams` with
`{"name": "test\n"}`.
2. The team is created with the trailing newline stored in `name`.
3. In **Settings → Teams**, click delete and type the team name to
confirm — the match fails, so the team cannot be deleted.
#### What changed
- `app/models/team.rb`: the existing `before_validation` now strips
control characters and surrounding whitespace before downcasing the
name. Names that reduce to blank (e.g. only newlines/tabs) are rejected
loudly by the existing `presence` validation.
- Fixing at the model layer covers the API and every other create/update
path, rather than relying on the frontend confirm-dialog `.trim()`
(which only handles leading/trailing whitespace, not internal control
characters).
Note: this prevents new malformed names. Any team already saved with a
control character can be made deletable again simply by renaming it (an
update re-runs the same sanitization).
| Input | Stored as | Result |
|---|---|---|
| `"test\n"` | `"test"` | valid, deletable |
| `"te\nst"` (internal) | `"test"` | valid |
| `"\t\n "` (only control/ws) | — | rejected: "Name must not be blank" |
| `"Customer Support"` | `"customer support"` | unchanged behavior |
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context)
---
app/models/team.rb | 2 +-
spec/models/team_spec.rb | 25 +++++++++++++++++++++++++
2 files changed, 26 insertions(+), 1 deletion(-)
diff --git a/app/models/team.rb b/app/models/team.rb
index 48990b488..15de8ab56 100644
--- a/app/models/team.rb
+++ b/app/models/team.rb
@@ -30,7 +30,7 @@ class Team < ApplicationRecord
uniqueness: { scope: :account_id }
before_validation do
- self.name = name.downcase if attribute_present?('name')
+ self.name = name.gsub(/[[:cntrl:]]/, '').strip.downcase if attribute_present?('name')
end
# Adds multiple members to the team
diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb
index cb55dba61..8272b5925 100644
--- a/spec/models/team_spec.rb
+++ b/spec/models/team_spec.rb
@@ -7,6 +7,31 @@ RSpec.describe Team do
it { is_expected.to have_many(:team_members) }
end
+ describe 'name normalization' do
+ let(:account) { create(:account) }
+
+ it 'downcases the name' do
+ team = create(:team, account: account, name: 'Customer Support')
+ expect(team.name).to eq('customer support')
+ end
+
+ it 'strips control characters and surrounding whitespace' do
+ team = create(:team, account: account, name: " Sales\n")
+ expect(team.name).to eq('sales')
+ end
+
+ it 'removes control characters embedded within the name' do
+ team = create(:team, account: account, name: "su\npport")
+ expect(team.name).to eq('support')
+ end
+
+ it 'is invalid when the name reduces to blank after sanitization' do
+ team = build(:team, account: account, name: "\t\n ")
+ expect(team).not_to be_valid
+ expect(team.errors[:name]).to include(I18n.t('errors.validations.presence'))
+ end
+ end
+
describe '#add_members' do
let(:team) { FactoryBot.create(:team) }
From 0e07a27c743629e55d9fae61f1d941b15ba47b7a Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Wed, 8 Jul 2026 02:18:07 -0700
Subject: [PATCH 11/12] fix: enforce inbox limits at model level (#14949)
Fixes https://linear.app/chatwoot/issue/CW-7559/inbox-limit-abuse
## Why
The regular inbox API checked limits in the controller, but WhatsApp
embedded signup creates inboxes through a service using `Inbox.create!`.
That let Enterprise account inbox limits be skipped for embedded signup.
## What this change does
- Adds an Inbox create-time validation hook in OSS and implements the
limit check in the Enterprise Inbox module.
- Removes the duplicate controller/helper limit check so the model is
the single enforcement point.
- Preserves the existing `402 Payment Required` API response for account
inbox limit failures.
- Keeps updates to existing inboxes allowed when an account is already
at its inbox limit.
## Validation
- `bundle exec rspec
spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
spec/enterprise/models/inbox_spec.rb`
---
.../api/v1/accounts/callbacks_controller.rb | 3 ++
.../channels/twilio_channels_controller.rb | 2 ++
.../api/v1/accounts/inboxes_controller.rb | 1 -
.../whatsapp/authorizations_controller.rb | 6 ++--
.../concerns/request_exception_handler.rb | 5 +--
.../instagram/callbacks_controller.rb | 10 ++++++
.../tiktok/callbacks_controller.rb | 10 ++++++
app/helpers/api/v1/inboxes_helper.rb | 6 ----
.../app/models/enterprise/concerns/inbox.rb | 6 ++++
lib/custom_exceptions/inbox/limit_exceeded.rb | 15 ++++++++
.../v1/accounts/callbacks_controller_spec.rb | 34 +++++++++++++++++++
.../lib/captain/base_task_service_spec.rb | 23 ++++++-------
.../conversation_completion_service_spec.rb | 10 ++++--
spec/enterprise/models/inbox_spec.rb | 23 +++++++++++++
.../audio_transcription_service_spec.rb | 8 ++++-
spec/lib/captain/base_task_service_spec.rb | 5 ++-
.../mappers/conversation_mapper_spec.rb | 1 +
.../whatsapp/channel_creation_service_spec.rb | 11 ++++++
18 files changed, 151 insertions(+), 28 deletions(-)
create mode 100644 lib/custom_exceptions/inbox/limit_exceeded.rb
create mode 100644 spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb
diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb
index 90cdf2418..08c0ffe43 100644
--- a/app/controllers/api/v1/accounts/callbacks_controller.rb
+++ b/app/controllers/api/v1/accounts/callbacks_controller.rb
@@ -6,6 +6,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token]
page_id = params[:page_id]
inbox_name = params[:inbox_name]
+
ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!(
page_id: page_id, user_access_token: user_access_token,
@@ -15,6 +16,8 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id)
end
+ rescue CustomExceptions::Inbox::LimitExceeded => e
+ render_error_response(e)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}"
diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
index f3b14d49f..1691b5489 100644
--- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
+++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
@@ -6,6 +6,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create
process_create
+ rescue CustomExceptions::Inbox::LimitExceeded => e
+ render_error_response(e)
rescue StandardError => e
render_could_not_create_error(e.message)
end
diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index 757af9b62..9f56c3817 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -2,7 +2,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
- before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
index d52f396fc..db94113d9 100644
--- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
+++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
@@ -8,8 +8,10 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
- rescue StandardError => e
+ rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
+ rescue StandardError => e
+ render_embedded_signup_error(e)
end
private
@@ -55,7 +57,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
render json: response
end
- def render_error_response(error)
+ def render_embedded_signup_error(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb
index 7f4e313b1..43d6edf1f 100644
--- a/app/controllers/concerns/request_exception_handler.rb
+++ b/app/controllers/concerns/request_exception_handler.rb
@@ -9,6 +9,7 @@ module RequestExceptionHandler
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
+ rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
end
private
@@ -40,8 +41,8 @@ module RequestExceptionHandler
render json: { error: message }, status: :not_found
end
- def render_could_not_create_error(message)
- render json: { error: sanitized_error_message(message) }, status: :unprocessable_entity
+ def render_could_not_create_error(error)
+ render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
end
def render_payment_required(message)
diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb
index cd317363c..e9065119f 100644
--- a/app/controllers/instagram/callbacks_controller.rb
+++ b/app/controllers/instagram/callbacks_controller.rb
@@ -11,6 +11,8 @@ class Instagram::CallbacksController < ApplicationController
end
process_successful_authorization
+ rescue CustomExceptions::Inbox::LimitExceeded => e
+ handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -47,6 +49,14 @@ class Instagram::CallbacksController < ApplicationController
redirect_to_error_page(error_info)
end
+ def handle_limit_error(error)
+ redirect_to_error_page(
+ 'error_type' => error.class.name,
+ 'code' => Rack::Utils.status_code(error.http_status),
+ 'error_message' => error.message
+ )
+ end
+
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb
index 20c0ee9c0..a39fec5ed 100644
--- a/app/controllers/tiktok/callbacks_controller.rb
+++ b/app/controllers/tiktok/callbacks_controller.rb
@@ -6,6 +6,8 @@ class Tiktok::CallbacksController < ApplicationController
return handle_ungranted_scopes_error unless all_scopes_granted?
process_successful_authorization
+ rescue CustomExceptions::Inbox::LimitExceeded => e
+ handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -36,6 +38,14 @@ class Tiktok::CallbacksController < ApplicationController
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
end
+ def handle_limit_error(error)
+ redirect_to_error_page(
+ error_type: error.class.name,
+ code: Rack::Utils.status_code(error.http_status),
+ error_message: error.message
+ )
+ end
+
# Handles the case when a user denies permissions or cancels the authorization flow
def handle_authorization_error
redirect_to_error_page(
diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb
index 8a10fa99c..6c64dd009 100644
--- a/app/helpers/api/v1/inboxes_helper.rb
+++ b/app/helpers/api/v1/inboxes_helper.rb
@@ -114,10 +114,4 @@ module Api::V1::InboxesHelper
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
-
- def validate_limit
- return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
-
- render_payment_required('Account limit exceeded. Upgrade to a higher plan')
- end
end
diff --git a/enterprise/app/models/enterprise/concerns/inbox.rb b/enterprise/app/models/enterprise/concerns/inbox.rb
index bdcd0fd63..b327878b4 100644
--- a/enterprise/app/models/enterprise/concerns/inbox.rb
+++ b/enterprise/app/models/enterprise/concerns/inbox.rb
@@ -8,5 +8,11 @@ module Enterprise::Concerns::Inbox
class_name: 'Captain::Assistant'
has_many :inbox_capacity_limits, dependent: :destroy
has_many :calls, dependent: :destroy_async
+
+ before_create :ensure_create_permitted
+ end
+
+ def ensure_create_permitted
+ raise CustomExceptions::Inbox::LimitExceeded.new({}) if account.inboxes.count >= account.usage_limits[:inboxes]
end
end
diff --git a/lib/custom_exceptions/inbox/limit_exceeded.rb b/lib/custom_exceptions/inbox/limit_exceeded.rb
new file mode 100644
index 000000000..9b5624929
--- /dev/null
+++ b/lib/custom_exceptions/inbox/limit_exceeded.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class CustomExceptions::Inbox::LimitExceeded < CustomExceptions::Base
+ def message
+ 'Account limit exceeded. Upgrade to a higher plan'
+ end
+
+ def to_hash
+ { error: message }
+ end
+
+ def http_status
+ :payment_required
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb
new file mode 100644
index 000000000..33cc95fc1
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Enterprise Callbacks API', type: :request do
+ describe 'POST /api/v1/accounts/{account.id}/callbacks/register_facebook_page' do
+ let(:account) { create(:account, limits: { inboxes: 1 }) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:params) do
+ {
+ user_access_token: 'user-token',
+ page_access_token: 'page-token',
+ page_id: '12345',
+ inbox_name: 'Facebook Inbox'
+ }
+ end
+
+ before do
+ create(:inbox, account: account)
+ end
+
+ it 'returns payment required before creating a Facebook channel when account inbox limit is reached' do
+ expect do
+ post "/api/v1/accounts/#{account.id}/callbacks/register_facebook_page",
+ headers: admin.create_new_auth_token,
+ params: params,
+ as: :json
+ end.not_to change(Channel::FacebookPage, :count)
+
+ expect(response).to have_http_status(:payment_required)
+ expect(response.parsed_body['error']).to eq('Account limit exceeded. Upgrade to a higher plan')
+ end
+ end
+end
diff --git a/spec/enterprise/lib/captain/base_task_service_spec.rb b/spec/enterprise/lib/captain/base_task_service_spec.rb
index fb970f726..9186874b7 100644
--- a/spec/enterprise/lib/captain/base_task_service_spec.rb
+++ b/spec/enterprise/lib/captain/base_task_service_spec.rb
@@ -5,6 +5,13 @@ RSpec.describe Captain::BaseTaskService, type: :model do
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:perform_result) { { message: 'Test response' } }
+ let(:exhausted_usage_limits) do
+ {
+ agents: ChatwootApp.max_limit,
+ inboxes: ChatwootApp.max_limit,
+ captain: { responses: { current_available: 0 } }
+ }
+ end
# Create a concrete test service class with enterprise module prepended
let(:test_service_class) do
@@ -38,9 +45,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when usage limit is exceeded' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'returns usage limit exceeded error' do
@@ -125,9 +130,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when the captain_responses quota is exhausted on Cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'returns usage limit exceeded error for services that do not opt into BYOK' do
@@ -162,9 +165,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when the captain_responses quota is exhausted on Cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'bypasses the 429 gate and returns the underlying result' do
@@ -249,9 +250,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when the captain_responses quota is exhausted on Cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'bypasses the 429 gate and returns the underlying result' do
diff --git a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb
index 8b059acc3..80b9ab1d8 100644
--- a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb
+++ b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb
@@ -166,9 +166,13 @@ RSpec.describe Captain::ConversationCompletionService do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(
+ {
+ agents: ChatwootApp.max_limit,
+ inboxes: ChatwootApp.max_limit,
+ captain: { responses: { current_available: 0 } }
+ }
+ )
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
allow(mock_chat).to receive(:ask).and_return(mock_response)
diff --git a/spec/enterprise/models/inbox_spec.rb b/spec/enterprise/models/inbox_spec.rb
index cfcbdd573..1dce2833b 100644
--- a/spec/enterprise/models/inbox_spec.rb
+++ b/spec/enterprise/models/inbox_spec.rb
@@ -134,6 +134,29 @@ RSpec.describe Inbox do
end
end
+ describe 'validations' do
+ describe 'account inbox limit' do
+ let(:account) { create(:account, limits: { inboxes: 1 }) }
+
+ before do
+ create(:inbox, account: account)
+ end
+
+ it 'prevents saving inboxes beyond the account limit' do
+ new_inbox = build(:inbox, account: account)
+
+ expect { new_inbox.save! }.to raise_error(CustomExceptions::Inbox::LimitExceeded, 'Account limit exceeded. Upgrade to a higher plan')
+ end
+
+ it 'does not block updates to existing inboxes when the account is at the limit' do
+ inbox = account.inboxes.first
+ inbox.name = 'Updated Inbox'
+
+ expect(inbox).to be_valid
+ end
+ end
+ end
+
describe 'audit log' do
context 'when inbox is created' do
it 'has associated audit log created' do
diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
index 265ce6c33..4881e8cf1 100644
--- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb
+++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
@@ -12,7 +12,13 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_MODEL') { |config| config.value = 'gpt-4o-mini' }
# Mock usage limits for transcription to be available
- allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } })
+ allow(account).to receive(:usage_limits).and_return(
+ {
+ agents: ChatwootApp.max_limit,
+ inboxes: ChatwootApp.max_limit,
+ captain: { responses: { current_available: 100 } }
+ }
+ )
end
describe '#perform' do
diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb
index b24a5c49c..2cb24ce04 100644
--- a/spec/lib/captain/base_task_service_spec.rb
+++ b/spec/lib/captain/base_task_service_spec.rb
@@ -385,7 +385,10 @@ RSpec.describe Captain::BaseTaskService do
describe '#prompt_from_file' do
it 'reads prompt from file' do
- allow(Rails.root).to receive(:join).and_return(instance_double(Pathname, read: 'Test prompt content'))
+ service
+ prompt_path = instance_double(Pathname, read: 'Test prompt content')
+ allow(Rails.root).to receive(:join).with('lib/integrations/openai/openai_prompts', 'test.liquid').and_return(prompt_path)
+
expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content')
end
end
diff --git a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb
index 75abc8518..988fb0116 100644
--- a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb
+++ b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb
@@ -32,6 +32,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
before do
account.enable_features('crm_integration')
+ allow(GlobalConfig).to receive(:get).and_return({})
allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' })
end
diff --git a/spec/services/whatsapp/channel_creation_service_spec.rb b/spec/services/whatsapp/channel_creation_service_spec.rb
index 983af6c78..e7016f6a4 100644
--- a/spec/services/whatsapp/channel_creation_service_spec.rb
+++ b/spec/services/whatsapp/channel_creation_service_spec.rb
@@ -60,6 +60,17 @@ describe Whatsapp::ChannelCreationService do
expect(inbox.name).to eq('Test Business WhatsApp')
expect(inbox.account).to eq(account)
end
+
+ it 'does not leave an orphan channel when inbox creation fails' do
+ allow(Inbox).to receive(:create!).and_wrap_original do |method, *args|
+ method.call(*args)
+ raise ActiveRecord::RecordInvalid, Inbox.new
+ end
+
+ expect do
+ expect { service.perform }.to raise_error(ActiveRecord::RecordInvalid)
+ end.not_to change(Channel::Whatsapp, :count)
+ end
end
context 'when channel already exists for the phone number' do
From f6c18f52258dfbcb41d7792914fbc77b92e90cb0 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Wed, 8 Jul 2026 15:31:03 +0530
Subject: [PATCH 12/12] feat: account calls dashboard index endpoint (#14780)
## Description
Adds a backend endpoint that powers an account-wide calls dashboard,
letting users list and filter all calls in the account.
## Linear Ticket
- https://linear.app/chatwoot/issue/UPM-28/voice-call-dashboard-view
## Type of change
- [ ] New feature (non-breaking change which adds functionality)
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
config/routes.rb | 1 +
...0_add_account_created_at_index_to_calls.rb | 7 ++
db/schema.rb | 1 +
.../api/v1/accounts/calls_controller.rb | 7 ++
enterprise/app/finders/call_finder.rb | 70 ++++++++++++
enterprise/app/models/call.rb | 11 ++
.../api/v1/accounts/calls/index.json.jbuilder | 11 ++
.../views/api/v1/models/_call.json.jbuilder | 40 +++++++
.../api/v1/accounts/calls_controller_spec.rb | 46 ++++++++
spec/enterprise/finders/call_finder_spec.rb | 108 ++++++++++++++++++
10 files changed, 302 insertions(+)
create mode 100644 db/migrate/20260622000000_add_account_created_at_index_to_calls.rb
create mode 100644 enterprise/app/controllers/api/v1/accounts/calls_controller.rb
create mode 100644 enterprise/app/finders/call_finder.rb
create mode 100644 enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder
create mode 100644 enterprise/app/views/api/v1/models/_call.json.jbuilder
create mode 100644 spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb
create mode 100644 spec/enterprise/finders/call_finder_spec.rb
diff --git a/config/routes.rb b/config/routes.rb
index c5e7ded2f..c31400719 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -240,6 +240,7 @@ Rails.application.routes.draw do
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
if ChatwootApp.enterprise?
+ resources :calls, only: [:index]
resources :whatsapp_calls, only: [:show] do
member do
post :accept
diff --git a/db/migrate/20260622000000_add_account_created_at_index_to_calls.rb b/db/migrate/20260622000000_add_account_created_at_index_to_calls.rb
new file mode 100644
index 000000000..9c15dcda6
--- /dev/null
+++ b/db/migrate/20260622000000_add_account_created_at_index_to_calls.rb
@@ -0,0 +1,7 @@
+class AddAccountCreatedAtIndexToCalls < ActiveRecord::Migration[7.1]
+ disable_ddl_transaction!
+
+ def change
+ add_index :calls, [:account_id, :created_at], algorithm: :concurrently
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1a676dd59..99d083d89 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -282,6 +282,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_30_000000) do
t.datetime "updated_at", null: false
t.index ["account_id", "contact_id"], name: "index_calls_on_account_id_and_contact_id"
t.index ["account_id", "conversation_id"], name: "index_calls_on_account_id_and_conversation_id"
+ t.index ["account_id", "created_at"], name: "index_calls_on_account_id_and_created_at"
t.index ["message_id"], name: "index_calls_on_message_id"
t.index ["provider", "provider_call_id"], name: "index_calls_on_provider_and_provider_call_id", unique: true
end
diff --git a/enterprise/app/controllers/api/v1/accounts/calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/calls_controller.rb
new file mode 100644
index 000000000..71772e4a0
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/calls_controller.rb
@@ -0,0 +1,7 @@
+class Api::V1::Accounts::CallsController < Api::V1::Accounts::EnterpriseAccountsController
+ def index
+ result = CallFinder.new(Current.user, Current.account, params).perform
+ @calls = result[:calls]
+ @calls_count = result[:count]
+ end
+end
diff --git a/enterprise/app/finders/call_finder.rb b/enterprise/app/finders/call_finder.rb
new file mode 100644
index 000000000..31d6ae5f2
--- /dev/null
+++ b/enterprise/app/finders/call_finder.rb
@@ -0,0 +1,70 @@
+class CallFinder
+ RESULTS_PER_PAGE = 25
+
+ def initialize(current_user, current_account, params)
+ @current_user = current_user
+ @current_account = current_account
+ @params = params
+ end
+
+ def perform
+ @calls = @current_account.calls
+ filter_by_visibility
+ filter_by_status
+ filter_by_direction
+ filter_by_inbox
+ filter_by_agent
+ filter_by_date_range
+
+ { calls: paginated_calls, count: @calls.count }
+ end
+
+ private
+
+ # Admins and report managers see the whole account; everyone else only sees
+ # calls they handled within conversations they can still access.
+ def filter_by_visibility
+ return if account_wide_access?
+
+ @calls = @calls.where(accepted_by_agent_id: @current_user.id, conversation_id: accessible_conversations)
+ end
+
+ def accessible_conversations
+ Conversations::PermissionFilterService.new(@current_account.conversations, @current_user, @current_account).perform.select(:id)
+ end
+
+ def account_wide_access?
+ account_user = Current.account_user
+ account_user&.administrator? || account_user&.custom_role&.permissions&.include?('report_manage')
+ end
+
+ def filter_by_status
+ @calls = @calls.where(status: Call.status_from_display(@params[:status])) if @params[:status].present?
+ end
+
+ def filter_by_direction
+ @calls = @calls.where(direction: Call.direction_from_label(@params[:direction])) if @params[:direction].present?
+ end
+
+ def filter_by_inbox
+ @calls = @calls.where(inbox_id: @params[:inbox_id]) if @params[:inbox_id].present?
+ end
+
+ def filter_by_agent
+ @calls = @calls.where(accepted_by_agent_id: @params[:agent_id]) if @params[:agent_id].present?
+ end
+
+ # since/until are unix timestamps, matching DateRangeHelper conventions.
+ def filter_by_date_range
+ return if @params[:since].blank? || @params[:until].blank?
+
+ @calls = @calls.where(created_at: Time.zone.at(@params[:since].to_i)..Time.zone.at(@params[:until].to_i))
+ end
+
+ def paginated_calls
+ @calls.includes(:contact, :inbox, :conversation, :accepted_by_agent)
+ .order(created_at: :desc)
+ .page(@params[:page] || 1)
+ .per(RESULTS_PER_PAGE)
+ end
+end
diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb
index 71dfac100..8c76103ea 100644
--- a/enterprise/app/models/call.rb
+++ b/enterprise/app/models/call.rb
@@ -78,6 +78,17 @@ class Call < ApplicationRecord
DISPLAY_DIRECTION[direction]
end
+ # Normalize filter values back to stored forms so API/dashboard clients can
+ # query using either the display value (inbound/outbound, in-progress) or the
+ # stored value (incoming/outgoing, in_progress).
+ def self.direction_from_label(value)
+ DISPLAY_DIRECTION.key(value) || value
+ end
+
+ def self.status_from_display(value)
+ value.to_s.tr('-', '_')
+ end
+
def ringing?
status == 'ringing'
end
diff --git a/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder
new file mode 100644
index 000000000..15f66ddc1
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder
@@ -0,0 +1,11 @@
+json.meta do
+ json.count @calls_count
+ json.current_page @calls.current_page
+ json.total_pages @calls.total_pages
+end
+
+json.payload do
+ json.array! @calls do |call|
+ json.partial! 'api/v1/models/call', formats: [:json], call: call
+ end
+end
diff --git a/enterprise/app/views/api/v1/models/_call.json.jbuilder b/enterprise/app/views/api/v1/models/_call.json.jbuilder
new file mode 100644
index 000000000..7a3531b39
--- /dev/null
+++ b/enterprise/app/views/api/v1/models/_call.json.jbuilder
@@ -0,0 +1,40 @@
+json.id call.id
+json.call_id call.provider_call_id
+json.provider call.provider
+json.status call.display_status
+json.direction call.direction_label
+json.duration_seconds call.duration_seconds
+json.end_reason call.end_reason
+json.started_at call.started_at&.to_i
+json.created_at call.created_at.to_i
+json.message_id call.message_id
+json.recording_url call.recording_url
+json.transcript call.transcript
+
+json.conversation do
+ json.id call.conversation_id
+ json.display_id call.conversation.display_id
+end
+
+json.inbox do
+ json.id call.inbox_id
+ json.name call.inbox.name
+end
+
+if call.accepted_by_agent
+ json.agent do
+ json.id call.accepted_by_agent.id
+ json.name call.accepted_by_agent.available_name
+ json.avatar call.accepted_by_agent.avatar_url
+ end
+else
+ json.agent nil
+end
+
+contact = call.contact
+json.contact do
+ json.id contact.id
+ json.name contact.name
+ json.phone_number contact.phone_number
+ json.avatar contact.avatar_url
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb
new file mode 100644
index 000000000..86e4fb317
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb
@@ -0,0 +1,46 @@
+require 'rails_helper'
+
+RSpec.describe 'Calls API', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, :with_phone_number, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
+ let!(:agent_call) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: contact,
+ accepted_by_agent: agent, status: 'completed', transcript: 'hello world')
+ end
+ let!(:other_call) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: contact, accepted_by_agent: admin)
+ end
+
+ before { create(:inbox_member, user: agent, inbox: inbox) }
+
+ describe 'GET /api/v1/accounts/:account_id/calls' do
+ it 'returns 401 when unauthenticated' do
+ get "/api/v1/accounts/#{account.id}/calls"
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'returns the whole account with sensitive fields for an administrator' do
+ get "/api/v1/accounts/#{account.id}/calls", headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ body = response.parsed_body
+ expect(body['payload'].map { |c| c['id'] }).to contain_exactly(agent_call.id, other_call.id)
+ item = body['payload'].find { |c| c['id'] == agent_call.id }
+ expect(item['transcript']).to eq('hello world')
+ expect(item['contact']['phone_number']).to eq(contact.phone_number)
+ end
+
+ it 'scopes the list to calls the agent accepted' do
+ get "/api/v1/accounts/#{account.id}/calls", headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ body = response.parsed_body
+ expect(body['meta']['count']).to eq(1)
+ expect(body['payload'].map { |c| c['id'] }).to contain_exactly(agent_call.id)
+ end
+ end
+end
diff --git a/spec/enterprise/finders/call_finder_spec.rb b/spec/enterprise/finders/call_finder_spec.rb
new file mode 100644
index 000000000..4f4607370
--- /dev/null
+++ b/spec/enterprise/finders/call_finder_spec.rb
@@ -0,0 +1,108 @@
+require 'rails_helper'
+
+describe CallFinder do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before { create(:inbox_member, user: agent, inbox: inbox) }
+
+ def perform(user, params = {})
+ Current.account = account
+ Current.account_user = account.account_users.find_by(user_id: user.id)
+ described_class.new(user, account, params).perform
+ end
+
+ describe 'visibility' do
+ let!(:agent_call) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, accepted_by_agent: agent)
+ end
+ let!(:other_call) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, accepted_by_agent: admin)
+ end
+
+ it 'lets an administrator see every call in the account' do
+ result = perform(admin)
+ expect(result[:count]).to eq(2)
+ expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id, other_call.id)
+ end
+
+ it 'lets an agent with report_manage see every call in the account' do
+ report_manager = create(:user, account: account, role: :agent)
+ custom_role = create(:custom_role, account: account, permissions: ['report_manage'])
+ account.account_users.find_by(user_id: report_manager.id).update!(custom_role: custom_role)
+
+ result = perform(report_manager)
+ expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id, other_call.id)
+ end
+
+ it 'limits a regular agent to calls they accepted in accessible conversations' do
+ result = perform(agent)
+ expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id)
+ end
+
+ it 'limits a custom-role agent without report_manage to their own accepted calls' do
+ scoped_agent = create(:user, account: account, role: :agent)
+ custom_role = create(:custom_role, account: account, permissions: ['conversation_manage'])
+ account.account_users.find_by(user_id: scoped_agent.id).update!(custom_role: custom_role)
+ create(:inbox_member, user: scoped_agent, inbox: inbox)
+ scoped_call = create(:call, account: account, inbox: inbox, conversation: conversation,
+ contact: conversation.contact, accepted_by_agent: scoped_agent)
+
+ result = perform(scoped_agent)
+ expect(result[:calls].map(&:id)).to contain_exactly(scoped_call.id)
+ end
+ end
+
+ describe 'filters' do
+ let(:inbox2) { create(:inbox, account: account) }
+ let(:conversation2) { create(:conversation, account: account, inbox: inbox2) }
+ let(:agent2) { create(:user, account: account, role: :agent) }
+ let!(:ringing) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
+ status: 'ringing', direction: :incoming, accepted_by_agent: agent)
+ end
+ let!(:in_progress) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
+ status: 'in_progress', direction: :incoming, accepted_by_agent: agent)
+ end
+ let!(:completed) do
+ create(:call, account: account, inbox: inbox2, conversation: conversation2, contact: conversation2.contact,
+ status: 'completed', direction: :outgoing, accepted_by_agent: agent2, created_at: 10.days.ago)
+ end
+
+ it 'filters by status using the display value' do
+ expect(perform(admin, status: 'in-progress')[:calls].map(&:id)).to contain_exactly(in_progress.id)
+ end
+
+ it 'filters by direction using the display label' do
+ expect(perform(admin, direction: 'outbound')[:calls].map(&:id)).to contain_exactly(completed.id)
+ end
+
+ it 'filters by inbox' do
+ expect(perform(admin, inbox_id: inbox2.id)[:calls].map(&:id)).to contain_exactly(completed.id)
+ end
+
+ it 'filters by agent' do
+ expect(perform(admin, agent_id: agent2.id)[:calls].map(&:id)).to contain_exactly(completed.id)
+ end
+
+ it 'filters by created_at date range' do
+ params = { since: 2.days.ago.to_i.to_s, until: 1.hour.from_now.to_i.to_s }
+ expect(perform(admin, params)[:calls].map(&:id)).to contain_exactly(ringing.id, in_progress.id)
+ end
+ end
+
+ describe 'account scoping' do
+ it 'never returns calls from another account' do
+ other_account = create(:account)
+ other_conversation = create(:conversation, account: other_account)
+ create(:call, account: other_account, inbox: other_conversation.inbox, conversation: other_conversation,
+ contact: other_conversation.contact)
+
+ expect(perform(admin)[:count]).to eq(0)
+ end
+ end
+end