Compare commits

..
18 changed files with 343 additions and 160 deletions
@@ -47,10 +47,18 @@ class Webhooks::WhatsappController < ActionController::API
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
return if metadata.blank?
Whatsapp::WebhookChannelFinderService.new(
display_phone_number: metadata[:display_phone_number],
phone_number_id: metadata[:phone_number_id]
).perform
phone_number = normalized_phone_number(metadata[:display_phone_number])
phone_number_id = metadata[:phone_number_id]
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
def normalized_phone_number(phone_number)
return if phone_number.blank?
phone_number = phone_number.to_s
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
end
def inactive_whatsapp_number?
@@ -0,0 +1,167 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
// The credit_usage stat from the overview stats endpoint:
// { current, previous, trend, daily: [{ date, value }] }
usage: { type: Object, default: null },
});
const { t } = useI18n();
const daily = computed(() => props.usage?.daily ?? []);
// Long ranges (90 days) get too many daily bars to read, so past this many
// days the series is bucketed into weeks.
const WEEKLY_THRESHOLD = 35;
const isWeekly = computed(() => daily.value.length > WEEKLY_THRESHOLD);
// One bucket per day, or per 7 consecutive days on long ranges (the last week
// may be partial and always includes today). A bucket is unrecorded (null)
// only when every day in it predates credit tracking.
const buckets = computed(() => {
if (!isWeekly.value) {
return daily.value.map(day => ({
start: day.date,
end: day.date,
value: day.value,
}));
}
const weeks = [];
for (let i = 0; i < daily.value.length; i += 7) {
const chunk = daily.value.slice(i, i + 7);
const recorded = chunk.filter(day => day.value !== null);
weeks.push({
start: chunk[0].date,
end: chunk[chunk.length - 1].date,
value: recorded.length
? +recorded.reduce((sum, day) => sum + day.value, 0).toFixed(2)
: null,
});
}
return weeks;
});
// Bar heights as a percentage of the peak bucket, with a small floor so even
// the lowest one stays visible. Unrecorded buckets render as muted
// placeholder bars.
const bars = computed(() => {
const max = Math.max(...buckets.value.map(bucket => bucket.value ?? 0), 0);
return buckets.value.map(bucket => ({
...bucket,
key: bucket.start,
recorded: bucket.value !== null,
height:
max === 0 || bucket.value === null
? 6
: Math.max(6, Math.round((bucket.value / max) * 100)),
}));
});
// Bucket dates arrive as date-only strings ('2026-07-17'), which new Date()
// would parse as midnight UTC and shift a day back for viewers west of UTC.
// Parse the parts as a local date so the label matches the server-side bucket.
const formatDate = date => {
const [year, month, day] = date.split('-').map(Number);
return new Date(year, month - 1, day).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
};
const barTooltip = bar => {
const period =
bar.start === bar.end
? formatDate(bar.start)
: `${formatDate(bar.start)} – ${formatDate(bar.end)}`;
const usage = bar.recorded
? `${bar.value} ${t('CAPTAIN.OVERVIEW.CREDITS.UNIT')}`
: t('CAPTAIN.OVERVIEW.CREDITS.NO_DATA');
return `${period} · ${usage}`;
};
const total = computed(() =>
props.usage ? props.usage.current.toLocaleString() : '—'
);
const trend = computed(() => {
if (!props.usage?.trend) return '';
const sign = props.usage.trend > 0 ? '+' : '';
return `${sign}${props.usage.trend}%`;
});
const axisStart = computed(() =>
daily.value.length ? formatDate(daily.value[0].date) : ''
);
const axisEnd = computed(() =>
daily.value.length ? formatDate(daily.value[daily.value.length - 1].date) : ''
);
</script>
<template>
<section
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-1.5">
<h3 class="text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.OVERVIEW.CREDITS.TITLE') }}
</h3>
<span
v-tooltip="$t('CAPTAIN.OVERVIEW.CREDITS.DISCLAIMER')"
class="cursor-help i-lucide-info size-3.5 text-n-slate-9"
/>
</div>
<div class="flex items-baseline gap-2">
<span class="text-2xl font-semibold tabular-nums text-n-slate-12">
{{ total }}
</span>
<span class="text-sm text-n-slate-11">
{{ $t('CAPTAIN.OVERVIEW.CREDITS.UNIT') }}
</span>
<span
v-if="trend"
class="text-sm font-medium tabular-nums text-n-slate-11"
>
{{ trend }}
</span>
</div>
</div>
<div class="flex items-center gap-2 mt-1">
<span class="rounded-full size-2.5 bg-n-brand" />
<span class="text-xs text-n-slate-11">
{{
isWeekly
? $t('CAPTAIN.OVERVIEW.CREDITS.LEGEND_WEEKLY')
: $t('CAPTAIN.OVERVIEW.CREDITS.LEGEND')
}}
</span>
</div>
</div>
<div class="flex flex-col justify-end flex-1">
<div class="flex items-end flex-1 gap-1 min-h-24">
<div
v-for="bar in bars"
:key="bar.key"
v-tooltip="barTooltip(bar)"
class="flex-1 rounded-t transition-colors"
:class="
bar.recorded
? 'bg-n-brand hover:bg-n-brand/70'
: 'bg-n-alpha-2 hover:bg-n-alpha-3'
"
:style="{ height: `${bar.height}%` }"
/>
</div>
<div class="flex items-center justify-between mt-3">
<span class="text-xs text-n-slate-10">{{ axisStart }}</span>
<span class="text-xs text-n-slate-10">{{ axisEnd }}</span>
</div>
</div>
</section>
</template>
@@ -57,13 +57,13 @@ const stats = computed(() => [
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.COVERAGE', { pct: approvedPct }) }}
</span>
</div>
<div class="w-full h-2 overflow-hidden rounded-full bg-n-alpha-2">
<div class="w-full h-2 mt-4 overflow-hidden rounded-full bg-n-alpha-2">
<div
class="h-full rounded-full bg-n-brand"
:style="{ width: `${approvedPct}%` }"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="grid grid-cols-3 gap-3 mt-4">
<RouterLink
v-for="stat in stats"
:key="stat.key"
@@ -26,7 +26,7 @@ const onActivate = () => {
<template>
<div
class="flex flex-col gap-3 p-5 group bg-n-solid-1"
class="flex flex-col gap-3 p-5 bg-n-solid-1"
:class="
clickable
? 'cursor-pointer transition-colors hover:bg-n-slate-2/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand'
@@ -43,7 +43,7 @@ const onActivate = () => {
<span
v-if="hint"
v-tooltip="hint"
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
class="cursor-help i-lucide-info size-3.5 text-n-slate-9"
/>
</div>
<div v-if="loading" class="flex items-end justify-between gap-2">
@@ -71,7 +71,7 @@ describe('useFacebookPageConnect', () => {
);
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
scope:
'pages_manage_metadata,pages_messaging,pages_show_list,pages_read_engagement',
'pages_manage_metadata,business_management,pages_messaging,pages_show_list,pages_read_engagement',
});
});
@@ -1,5 +1,6 @@
export const FACEBOOK_PAGE_SCOPES = [
'pages_manage_metadata',
'business_management',
'pages_messaging',
'pages_show_list',
'pages_read_engagement',
@@ -460,6 +460,14 @@
"PENDING": "Pending FAQs",
"DOCUMENTS": "Documents"
},
"CREDITS": {
"TITLE": "Credit usage",
"UNIT": "credits",
"LEGEND": "Daily credits used",
"LEGEND_WEEKLY": "Weekly credits used",
"NO_DATA": "No data recorded",
"DISCLAIMER": "Shows credits used by this assistant only. Your account's total credit usage also includes other assistants and features."
},
"LINKS": {
"DOCS": {
"TITLE": "Captain docs",
@@ -13,6 +13,7 @@ import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overvi
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
import AssistantDrilldownDrawer from 'dashboard/components-next/captain/pageComponents/overview/AssistantDrilldownDrawer.vue';
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
import CreditUsageCard from 'dashboard/components-next/captain/pageComponents/overview/CreditUsageCard.vue';
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
import CoverageBanner from 'dashboard/components-next/captain/pageComponents/overview/CoverageBanner.vue';
@@ -205,7 +206,10 @@ const closeDrilldown = () => {
/>
</div>
<KnowledgeCard :knowledge="stats?.knowledge" />
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<KnowledgeCard :knowledge="stats?.knowledge" />
<CreditUsageCard :usage="stats?.credit_usage" />
</div>
<QuickLinks />
</div>
+5 -5
View File
@@ -153,11 +153,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
end
def get_channel_from_wb_payload(wb_params)
metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {}
Whatsapp::WebhookChannelFinderService.new(
display_phone_number: metadata[:display_phone_number],
phone_number_id: metadata[:phone_number_id]
).perform
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
# validate to ensure the phone number id matches the whatsapp channel
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
end
@@ -6,10 +6,12 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
end
def perform_reply
return send_template_message if template_params.present?
return send_session_message if message.conversation.can_reply?
message.update!(status: :failed, external_error: I18n.t('errors.whatsapp.message_outside_messaging_window'))
should_send_template_message = template_params.present? || !message.conversation.can_reply?
if should_send_template_message
send_template_message
else
send_session_message
end
end
def send_template_message
@@ -1,35 +0,0 @@
# Resolves the WhatsApp channel for an inbound WhatsApp Cloud webhook. Meta's
# display_phone_number can arrive formatted or in a country-specific variant (e.g. Brazil
# omits the mobile 9, Argentina adds a digit after the country code), so we try the
# raw digits first and then a normalized fallback, accepting only a candidate whose
# phone_number_id matches.
class Whatsapp::WebhookChannelFinderService
def initialize(display_phone_number:, phone_number_id:)
@display_phone_number = display_phone_number
@phone_number_id = phone_number_id
end
def perform
return if digits.blank?
candidates = [
Channel::Whatsapp.find_by(phone_number: "+#{digits}"),
channel_by_normalized_number
]
candidates.compact.find { |channel| channel.provider_config['phone_number_id'] == @phone_number_id }
end
private
def digits
@digits ||= @display_phone_number.to_s.gsub(/[^0-9]/, '')
end
def channel_by_normalized_number
normalizer = Whatsapp::PhoneNumberNormalizationService::NORMALIZERS
.lazy.map(&:new).find { |n| n.handles_country?(digits) }
return unless normalizer
Channel::Whatsapp.find_by(phone_number: "+#{normalizer.normalize(digits)}")
end
end
-1
View File
@@ -154,7 +154,6 @@ en:
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
message_outside_messaging_window: 'Message not sent because the WhatsApp 24-hour customer service window is closed and no template parameters were provided. Send an approved template message instead.'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -0,0 +1,124 @@
# Computes per-assistant credit consumption (from agent_sessions) for the
# Captain Overview page: window totals for the trend plus a per-day series for
# the usage chart.
#
# Days are bucketed on the viewer's calendar day, so a completed day's sum never
# changes. Those sums are cached per (assistant, timezone, date) with a long TTL
# and only the still-accruing current day is computed live on every request.
class Captain::AssistantCreditUsageBuilder
CACHE_TTL = 30.days
# Credit tracking on agent_sessions began on this date. Earlier days are still
# returned in `daily` but with a nil value ("no data recorded") and are
# excluded from the queried span and the window totals.
DATA_EPOCH = Date.new(2026, 7, 17)
def initialize(assistant, window)
@assistant = assistant
@window = window
end
# => { current:, previous:, daily: [{ date:, value: }, ...] }
# `daily` covers the current window only; `previous` is the total for the
# preceding equal-length day span, used for the trend. A nil daily value means
# the day predates credit tracking.
def build
sums = daily_sums(recorded_dates)
{
current: total(sums, current_dates),
previous: total(sums, previous_dates),
daily: current_dates.map { |date| { date: date, value: sums[date] } }
}
end
private
attr_reader :assistant, :window
# Credit usage is bucketed on calendar days (unlike the rolling timestamp
# windows of the other metrics): a day-count range means exactly N calendar
# days ending today in the viewer's timezone, and month ranges follow the
# window's calendar boundaries.
def current_dates
@current_dates ||= if day_range?
(today - (window.range.to_i - 1))..today
else
to_dates(window.current)
end
end
# Day ranges derive the comparison span from the current dates (the N days
# right before them), since current_dates redefines the window as N calendar
# days ending today. Named month ranges follow window.previous so the trend
# compares the same periods as every other overview metric.
def previous_dates
if day_range?
(current_dates.first - current_dates.count)..(current_dates.first - 1)
else
to_dates(window.previous)
end
end
def day_range?
window.range.match?(/\A\d+\z/)
end
def to_dates(range)
range.first.in_time_zone(timezone).to_date..range.last.in_time_zone(timezone).to_date
end
# Pre-epoch days are never queried or cached; they fall out of the sums hash
# and surface as nil daily values / zero contribution to totals. While the
# previous span is fully pre-epoch this leaves `previous` at 0, so the
# frontend hides the trend.
def recorded_dates
(previous_dates.to_a + current_dates.to_a).select { |date| date >= DATA_EPOCH }
end
def daily_sums(dates)
cacheable = dates.select { |date| date < today }
cached = Rails.cache.read_multi(*cacheable.map { |date| cache_key(date) })
missing = dates.reject { |date| cached.key?(cache_key(date)) }
live = live_sums(missing)
missing.each do |date|
Rails.cache.write(cache_key(date), live[date], expires_in: CACHE_TTL) if date < today
end
dates.index_with { |date| cached.fetch(cache_key(date)) { live[date] } }
end
# One grouped scan covering the span of the requested dates. Zero-filled via
# groupdate so cache entries exist even for days without sessions.
def live_sums(dates)
return {} if dates.empty?
span = dates.min.in_time_zone(timezone).beginning_of_day..dates.max.in_time_zone(timezone).end_of_day
Captain::AgentSession
.where(assistant_id: assistant.id, created_at: span)
.group_by_day(:created_at, time_zone: timezone, range: span)
.sum(:credits_consumed)
.to_h { |day, value| [day.to_date, (value || 0).round(2)] }
end
def total(sums, dates)
dates.sum { |date| sums[date].to_f }.round(2)
end
def today
@today ||= Time.current.in_time_zone(timezone).to_date
end
def timezone
window.timezone
end
def cache_key(date)
"captain_credit_usage/#{assistant.id}/#{timezone_name}/#{date}"
end
def timezone_name
@timezone_name ||= timezone.is_a?(String) ? timezone : timezone.name
end
end
@@ -57,6 +57,7 @@ class Captain::AssistantStatsBuilder
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),
credit_usage: credit_usage,
knowledge: knowledge
}
end
@@ -181,6 +182,13 @@ class Captain::AssistantStatsBuilder
rate(reopened, resolved_scope.distinct.count(:conversation_id))
end
# Credits consumed by the assistant's agent sessions: window totals packed like
# the other trend metrics, plus the per-day series for the usage chart.
def credit_usage
usage = Captain::AssistantCreditUsageBuilder.new(assistant, window).build
pack(usage[:current], usage[:previous], :percent).merge(daily: usage[:daily])
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(
@@ -14,7 +14,7 @@ class Captain::AssistantStatsWindow
DEFAULT_RANGE = '30'.freeze
ALLOWED_RANGES = %w[7 30 90 this_month last_month].freeze
attr_reader :range
attr_reader :range, :timezone
def initialize(range = DEFAULT_RANGE, timezone_offset = nil)
@range = ALLOWED_RANGES.include?(range.to_s) ? range.to_s : DEFAULT_RANGE
@@ -27,7 +27,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
expect(metrics.keys).to contain_exactly(
:conversations_handled, :auto_resolution_rate, :handoff_rate,
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
:hours_saved, :reopen_rate, :conversation_depth, :credit_usage, :knowledge
)
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
end
@@ -345,94 +345,6 @@ RSpec.describe Webhooks::WhatsappEventsJob do
end.not_to change(Conversation, :count)
end
it 'finds channel using normalized Brazil phone number when display_phone_number is missing the 9 digit' do
brazil_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false)
wb_params = {
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
metadata: {
phone_number_id: brazil_channel.provider_config['phone_number_id'],
display_phone_number: '554199887766'
}
}
}]
}]
}
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: brazil_channel.inbox, params: wb_params)
job.perform_now(wb_params)
end
it 'finds channel using normalized Argentina phone number when display_phone_number has extra 9 digit' do
argentina_channel = create(:channel_whatsapp, phone_number: '+541112345678', provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false)
wb_params = {
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
metadata: {
phone_number_id: argentina_channel.provider_config['phone_number_id'],
display_phone_number: '5491112345678'
}
}
}]
}]
}
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: argentina_channel.inbox, params: wb_params)
job.perform_now(wb_params)
end
it 'finds channel when display_phone_number contains formatting characters' do
formatted_channel = create(:channel_whatsapp, phone_number: '+14155552671', provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false)
wb_params = {
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
metadata: {
phone_number_id: formatted_channel.provider_config['phone_number_id'],
display_phone_number: '+1 415-555-2671'
}
}
}]
}]
}
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: formatted_channel.inbox, params: wb_params)
job.perform_now(wb_params)
end
it 'prefers the phone_number_id match when a raw display_phone_number collision exists' do
normalized_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false)
create(:channel_whatsapp, phone_number: '+554199887766', provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false).tap do |raw_channel|
raw_channel.update!(provider_config: raw_channel.provider_config.merge('phone_number_id' => 'other-id'))
end
wb_params = {
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
metadata: {
phone_number_id: normalized_channel.provider_config['phone_number_id'],
display_phone_number: '554199887766'
}
}
}]
}]
}
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: normalized_channel.inbox, params: wb_params)
job.perform_now(wb_params)
end
it 'will not enque Whatsapp::IncomingMessageWhatsappCloudService when invalid phone number id' do
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
validate_provider_config: false)
@@ -72,21 +72,6 @@ describe Whatsapp::SendOnWhatsappService do
expect(message.reload.source_id).to eq('123456789')
end
it 'fails a free-form message without contacting the provider when outside the 24 hour limit' do
create(:message, message_type: :incoming, content: 'test', created_at: 25.hours.ago,
conversation: conversation, account: conversation.account)
message = create(:message, message_type: :outgoing, content: 'test',
conversation: conversation, account: conversation.account)
expect(Whatsapp::TemplateProcessorService).not_to receive(:new)
described_class.new(message: message).perform
expect(message.reload.status).to eq('failed')
expect(message.external_error).to eq(I18n.t('errors.whatsapp.message_outside_messaging_window'))
expect(a_request(:post, 'https://waba.360dialog.io/v1/messages')).not_to have_been_made
end
it 'marks message as failed when template name is blank' do
processor = instance_double(Whatsapp::TemplateProcessorService)
allow(Whatsapp::TemplateProcessorService).to receive(:new).and_return(processor)