diff --git a/AGENTS.md b/AGENTS.md
index ef1d3b26d..dc6f45f90 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -48,6 +48,7 @@
- Remove dead/unreachable/unused code
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
- Don't reference Claude in commit messages
+- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
## Project-Specific
@@ -78,3 +79,4 @@ Practical checklist for any change impacting core logic or public APIs
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
+- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
diff --git a/app/builders/year_in_review_builder.rb b/app/builders/year_in_review_builder.rb
new file mode 100644
index 000000000..545fe8029
--- /dev/null
+++ b/app/builders/year_in_review_builder.rb
@@ -0,0 +1,74 @@
+class YearInReviewBuilder
+ attr_reader :account, :user_id, :year
+
+ def initialize(account:, user_id:, year:)
+ @account = account
+ @user_id = user_id
+ @year = year
+ end
+
+ def build
+ {
+ year: year,
+ total_conversations: total_conversations_count,
+ busiest_day: busiest_day_data,
+ support_personality: support_personality_data
+ }
+ end
+
+ private
+
+ def year_range
+ @year_range ||= begin
+ start_time = Time.zone.local(year, 1, 1).beginning_of_day
+ end_time = Time.zone.local(year, 12, 31).end_of_day
+ start_time..end_time
+ end
+ end
+
+ def total_conversations_count
+ account.conversations
+ .where(assignee_id: user_id, created_at: year_range)
+ .count
+ end
+
+ def busiest_day_data
+ daily_counts = account.conversations
+ .where(assignee_id: user_id, created_at: year_range)
+ .group_by_day(:created_at, range: year_range, time_zone: Time.zone)
+ .count
+
+ return nil if daily_counts.empty?
+
+ busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
+
+ return nil if count.zero?
+
+ {
+ date: busiest_date.strftime('%b %d'),
+ count: count
+ }
+ end
+
+ def support_personality_data
+ response_time = average_response_time
+
+ return { avg_response_time_seconds: 0 } if response_time.nil?
+
+ {
+ avg_response_time_seconds: response_time.to_i
+ }
+ end
+
+ def average_response_time
+ avg_time = account.reporting_events
+ .where(
+ name: 'first_response',
+ user_id: user_id,
+ created_at: year_range
+ )
+ .average(:value)
+
+ avg_time&.to_f
+ end
+end
diff --git a/app/controllers/api/v2/accounts/year_in_reviews_controller.rb b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb
new file mode 100644
index 000000000..7946614bb
--- /dev/null
+++ b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb
@@ -0,0 +1,26 @@
+class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
+ def show
+ year = params[:year] || 2025
+ cache_key = "year_in_review_#{Current.account.id}_#{year}"
+
+ cached_data = Current.user.ui_settings&.dig(cache_key)
+
+ if cached_data.present?
+ render json: cached_data
+ else
+ builder = YearInReviewBuilder.new(
+ account: Current.account,
+ user_id: Current.user.id,
+ year: year
+ )
+
+ data = builder.build
+
+ ui_settings = Current.user.ui_settings || {}
+ ui_settings[cache_key] = data
+ Current.user.update(ui_settings: ui_settings)
+
+ render json: data
+ end
+ end
+end
diff --git a/app/javascript/dashboard/api/yearInReview.js b/app/javascript/dashboard/api/yearInReview.js
new file mode 100644
index 000000000..fb0661804
--- /dev/null
+++ b/app/javascript/dashboard/api/yearInReview.js
@@ -0,0 +1,16 @@
+/* global axios */
+import ApiClient from './ApiClient';
+
+class YearInReviewAPI extends ApiClient {
+ constructor() {
+ super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
+ }
+
+ get(year) {
+ return axios.get(`${this.url}`, {
+ params: { year },
+ });
+ }
+}
+
+export default new YearInReviewAPI();
diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
index 2a383534e..38144d0d7 100644
--- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
+++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
@@ -14,6 +14,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
+import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
@@ -651,6 +652,7 @@ const menuItems = computed(() => {
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue
index 7175edd52..37f123570 100644
--- a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue
@@ -1,11 +1,14 @@
+
+
+
+
+
+
+
+
+ {{ t('YEAR_IN_REVIEW.SHARE_MODAL.PREPARING') }}
+
+
+
+
+
+
+
+ {{ t('YEAR_IN_REVIEW.SHARE_MODAL.TITLE') }}
+
+
+
+
+
+
![Year in Review]()
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/year-in-review/YearInReviewBanner.vue b/app/javascript/dashboard/components-next/year-in-review/YearInReviewBanner.vue
new file mode 100644
index 000000000..7c262f3b2
--- /dev/null
+++ b/app/javascript/dashboard/components-next/year-in-review/YearInReviewBanner.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+ {{ t('YEAR_IN_REVIEW.BANNER.TITLE', { year: currentYear }) }}
+
+
+
+
+
+
![Year in Review]()
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/year-in-review/YearInReviewModal.vue b/app/javascript/dashboard/components-next/year-in-review/YearInReviewModal.vue
new file mode 100644
index 000000000..b6cf23975
--- /dev/null
+++ b/app/javascript/dashboard/components-next/year-in-review/YearInReviewModal.vue
@@ -0,0 +1,384 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('YEAR_IN_REVIEW.LOADING') }}
+
+
+
+
+
+
+
+ {{ t('YEAR_IN_REVIEW.ERROR') }}
+
+
{{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/year-in-review/slides/BusiestDaySlide.vue b/app/javascript/dashboard/components-next/year-in-review/slides/BusiestDaySlide.vue
new file mode 100644
index 000000000..3f76bfbd4
--- /dev/null
+++ b/app/javascript/dashboard/components-next/year-in-review/slides/BusiestDaySlide.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+ {{ t('YEAR_IN_REVIEW.BUSIEST_DAY.TITLE') }}
+
+
+ {{ busiestDay.date }}
+
+
+
+
![Coffee]()
+
+
+
+
+
+
![Quote]()
+
+
+ {{
+ t('YEAR_IN_REVIEW.BUSIEST_DAY.MESSAGE', {
+ count: busiestDay.count,
+ })
+ }}
+ {{ performanceHelperText }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/year-in-review/slides/ConversationsSlide.vue b/app/javascript/dashboard/components-next/year-in-review/slides/ConversationsSlide.vue
new file mode 100644
index 000000000..7f92fe05a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/year-in-review/slides/ConversationsSlide.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+ {{ t('YEAR_IN_REVIEW.CONVERSATIONS.TITLE') }}
+
+
+ {{ formatNumber(totalConversations) }}
+
+
+ {{ t('YEAR_IN_REVIEW.CONVERSATIONS.SUBTITLE') }}
+
+
+
+
![Cloud]()
+
+
+
+
+
![Quote]()
+
+ {{ performanceHelperText }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/year-in-review/slides/IntroSlide.vue b/app/javascript/dashboard/components-next/year-in-review/slides/IntroSlide.vue
new file mode 100644
index 000000000..8f47746fe
--- /dev/null
+++ b/app/javascript/dashboard/components-next/year-in-review/slides/IntroSlide.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ {{ year }}
+
+
+ {{ t('YEAR_IN_REVIEW.TITLE') }}
+
+
+
+
![Candles]()
+
+
diff --git a/app/javascript/dashboard/components-next/year-in-review/slides/PersonalitySlide.vue b/app/javascript/dashboard/components-next/year-in-review/slides/PersonalitySlide.vue
new file mode 100644
index 000000000..016151c12
--- /dev/null
+++ b/app/javascript/dashboard/components-next/year-in-review/slides/PersonalitySlide.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
![Clock]()
+
+
+
+ {{ t('YEAR_IN_REVIEW.PERSONALITY.TITLE') }}
+
+
+ {{ personality }}
+
+
+
+
+
+
+
![Quote]()
+
+ {{ personalityMessage }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/year-in-review/slides/ThankYouSlide.vue b/app/javascript/dashboard/components-next/year-in-review/slides/ThankYouSlide.vue
new file mode 100644
index 000000000..79c6293b9
--- /dev/null
+++ b/app/javascript/dashboard/components-next/year-in-review/slides/ThankYouSlide.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ {{ t('YEAR_IN_REVIEW.THANK_YOU.TITLE', { year }) }}
+
+
+ {{
+ t('YEAR_IN_REVIEW.THANK_YOU.MESSAGE', { nextYear: Number(year) + 1 })
+ }}
+
+
+
![Chatwoot Team Signature]()
+
+
+
+
diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js
index 17121fc61..a55f54165 100644
--- a/app/javascript/dashboard/i18n/locale/en/index.js
+++ b/app/javascript/dashboard/i18n/locale/en/index.js
@@ -38,6 +38,7 @@ import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
+import yearInReview from './yearInReview.json';
export default {
...advancedFilters,
@@ -80,4 +81,5 @@ export default {
...whatsappTemplates,
...contentTemplates,
...mfa,
+ ...yearInReview,
};
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index 4e51ef37a..3017e9464 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -234,6 +234,7 @@
"CONTACT_SUPPORT": "Contact support",
"SELECTOR_SUBTITLE": "Select an account from the following list",
"PROFILE_SETTINGS": "Profile settings",
+ "YEAR_IN_REVIEW": "Year in Review",
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
"APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
diff --git a/app/javascript/dashboard/i18n/locale/en/yearInReview.json b/app/javascript/dashboard/i18n/locale/en/yearInReview.json
new file mode 100644
index 000000000..d72e0c679
--- /dev/null
+++ b/app/javascript/dashboard/i18n/locale/en/yearInReview.json
@@ -0,0 +1,64 @@
+{
+ "YEAR_IN_REVIEW": {
+ "TITLE": "Year in Review",
+ "LOADING": "Loading your year in review...",
+ "ERROR": "Failed to load year in review",
+ "CLOSE": "Close",
+ "CONVERSATIONS": {
+ "TITLE": "You have handled",
+ "SUBTITLE": "conversations",
+ "FALLBACK": "This year wasn't about the numbers. It was about showing up.",
+ "COMPARISON": {
+ "0_50": "You showed up, and that's how every good inbox begins.",
+ "50_100": "You kept the replies flowing and the conversations alive.",
+ "100_500": "You handled serious volume and kept everything on track.",
+ "500_2000": "You kept things moving while the volume kept climbing.",
+ "2000_10000": "You ran high traffic through your inbox without breaking a sweat.",
+ "10000_PLUS": "That's a full city of customers knocking on your door. You made it look effortless."
+ }
+ },
+ "BUSIEST_DAY": {
+ "TITLE": "Your busiest day was",
+ "MESSAGE": "{count} conversations that day.",
+ "COMPARISON": {
+ "0_5": "A warm-up lap that barely woke the inbox.",
+ "5_10": "Enough action to justify a second cup of coffee.",
+ "10_25": "Things got busy and the inbox stayed on its toes.",
+ "25_50": "A proper rush that barely broke a sweat.",
+ "50_100": "Controlled chaos, handled like a normal Tuesday.",
+ "100_500": "Absolute dumpster fire, somehow still shipping replies.",
+ "500_PLUS": "The inbox lost all chill and never slowed down."
+ }
+ },
+ "PERSONALITY": {
+ "TITLE": "Your support personality is",
+ "MESSAGES": {
+ "SWIFT_HELPER": "You replied in {time} on average. Faster than most notifications.",
+ "QUICK_RESPONDER": "You replied in {time} on average. The inbox barely waited.",
+ "STEADY_SUPPORT": "You replied in {time} on average. Calm pace, solid replies.",
+ "THOUGHTFUL_ADVISOR": "You replied in {time} on average. Took the time to get it right."
+ }
+ },
+ "THANK_YOU": {
+ "TITLE": "Congratulations on surviving the inbox of {year}.",
+ "MESSAGE": "Thank you for your incredible dedication to supporting customers throughout this year. Your hard work has made a real difference, and we're grateful to have you on this journey. Here's to making {nextYear} even better together!"
+ },
+ "SHARE_MODAL": {
+ "TITLE": "Share Your Year in Review",
+ "PREPARING": "Preparing your image...",
+ "DOWNLOAD": "Download",
+ "SHARE_TITLE": "My {year} Year in Review",
+ "SHARE_TEXT": "Check out my {year} Year in Review with Chatwoot!",
+ "BRANDING": "Made with Chatwoot"
+ },
+ "BANNER": {
+ "TITLE": "Your {year} Year in Review is here",
+ "BUTTON": "See your impact"
+ },
+ "NAVIGATION": {
+ "PREVIOUS": "Previous",
+ "NEXT": "Next",
+ "SHARE": "Share"
+ }
+ }
+}
diff --git a/config/routes.rb b/config/routes.rb
index 0ac001612..15ab79822 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -203,9 +203,15 @@ Rails.application.routes.draw do
delete :avatar, on: :member
post :sync_templates, on: :member
get :health, on: :member
+ if ChatwootApp.enterprise?
+ resource :conference, only: %i[create destroy], controller: 'conference' do
+ get :token, on: :member
+ end
+ end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
end
+
resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do
delete :destroy
@@ -419,6 +425,7 @@ Rails.application.routes.draw do
get :bot_metrics
end
end
+ resource :year_in_review, only: [:show]
resources :live_reports, only: [] do
collection do
get :conversation_metrics
@@ -550,13 +557,9 @@ Rails.application.routes.draw do
resources :delivery_status, only: [:create]
if ChatwootApp.enterprise?
- resource :voice, only: [], controller: 'voice' do
- collection do
- post 'call/:phone', action: :call_twiml
- post 'status/:phone', action: :status
- post 'conference_status/:phone', action: :conference_status
- end
- end
+ post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call
+ post 'voice/status/:phone', to: 'voice#status', as: :voice_status
+ post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status
end
end
diff --git a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
new file mode 100644
index 000000000..3d802fc31
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
@@ -0,0 +1,58 @@
+class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
+ before_action :set_voice_inbox_for_conference
+
+ def token
+ render json: Voice::Provider::Twilio::TokenService.new(
+ inbox: @voice_inbox,
+ user: Current.user,
+ account: Current.account
+ ).generate
+ end
+
+ def create
+ conversation = fetch_conversation_by_display_id
+ ensure_call_sid!(conversation)
+
+ conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
+ conference_sid = conference_service.ensure_conference_sid
+ conference_service.mark_agent_joined(user: current_user)
+
+ render json: {
+ status: 'success',
+ id: conversation.display_id,
+ conference_sid: conference_sid,
+ using_webrtc: true
+ }
+ end
+
+ def destroy
+ conversation = fetch_conversation_by_display_id
+ Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
+ render json: { status: 'success', id: conversation.display_id }
+ end
+
+ private
+
+ def ensure_call_sid!(conversation)
+ return conversation.identifier if conversation.identifier.present?
+
+ incoming_sid = params.require(:call_sid)
+
+ conversation.update!(identifier: incoming_sid)
+ incoming_sid
+ end
+
+ def set_voice_inbox_for_conference
+ @voice_inbox = Current.account.inboxes.find(params[:inbox_id])
+ authorize @voice_inbox, :show?
+ end
+
+ def fetch_conversation_by_display_id
+ cid = params[:conversation_id]
+ raise ActiveRecord::RecordNotFound, 'conversation_id required' if cid.blank?
+
+ conversation = @voice_inbox.conversations.find_by!(display_id: cid)
+ authorize conversation, :show?
+ conversation
+ end
+end
diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb
index f25e819c2..aa2696b31 100644
--- a/enterprise/app/controllers/twilio/voice_controller.rb
+++ b/enterprise/app/controllers/twilio/voice_controller.rb
@@ -151,9 +151,8 @@ class Twilio::VoiceController < ApplicationController
end
def conference_status_callback_url
- host = ENV.fetch('FRONTEND_URL', '')
phone_digits = inbox_channel.phone_number.delete_prefix('+')
- "#{host}/twilio/voice/conference_status/#{phone_digits}"
+ Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end
def find_conversation_for_conference!(friendly_name:, call_sid:)
diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb
index 1f00e74d1..dbb9931df 100644
--- a/enterprise/app/models/channel/voice.rb
+++ b/enterprise/app/models/channel/voice.rb
@@ -42,11 +42,13 @@ class Channel::Voice < ApplicationRecord
false
end
- def initiate_call(to:)
+ def initiate_call(to:, conference_sid: nil, agent_id: nil)
case provider
when 'twilio'
- Voice::Provider::TwilioAdapter.new(self).initiate_call(
- to: to
+ Voice::Provider::Twilio::Adapter.new(self).initiate_call(
+ to: to,
+ conference_sid: conference_sid,
+ agent_id: agent_id
)
else
raise "Unsupported voice provider: #{provider}"
@@ -56,12 +58,12 @@ class Channel::Voice < ApplicationRecord
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
- "#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/call/#{digits}"
+ Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
- "#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status/#{digits}"
+ Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
end
private
@@ -87,7 +89,6 @@ class Channel::Voice < ApplicationRecord
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
- # twilio_client and initiate_twilio_call moved to Voice::Provider::TwilioAdapter
def provider_config_hash
if provider_config.is_a?(Hash)
diff --git a/enterprise/app/services/voice/provider/twilio/adapter.rb b/enterprise/app/services/voice/provider/twilio/adapter.rb
new file mode 100644
index 000000000..061143f03
--- /dev/null
+++ b/enterprise/app/services/voice/provider/twilio/adapter.rb
@@ -0,0 +1,52 @@
+class Voice::Provider::Twilio::Adapter
+ def initialize(channel)
+ @channel = channel
+ end
+
+ def initiate_call(to:, conference_sid: nil, agent_id: nil)
+ call = twilio_client.calls.create(**call_params(to))
+
+ {
+ provider: 'twilio',
+ call_sid: call.sid,
+ status: call.status,
+ call_direction: 'outbound',
+ requires_agent_join: true,
+ agent_id: agent_id,
+ conference_sid: conference_sid
+ }
+ end
+
+ private
+
+ def call_params(to)
+ phone_digits = @channel.phone_number.delete_prefix('+')
+
+ {
+ from: @channel.phone_number,
+ to: to,
+ url: twilio_call_twiml_url(phone_digits),
+ status_callback: twilio_call_status_url(phone_digits),
+ status_callback_event: %w[
+ initiated ringing answered completed failed busy no-answer canceled
+ ],
+ status_callback_method: 'POST'
+ }
+ end
+
+ def twilio_call_twiml_url(phone_digits)
+ Rails.application.routes.url_helpers.twilio_voice_call_url(phone: phone_digits)
+ end
+
+ def twilio_call_status_url(phone_digits)
+ Rails.application.routes.url_helpers.twilio_voice_status_url(phone: phone_digits)
+ end
+
+ def twilio_client
+ Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
+ end
+
+ def config
+ @config ||= @channel.provider_config_hash
+ end
+end
diff --git a/enterprise/app/services/voice/provider/twilio/conference_service.rb b/enterprise/app/services/voice/provider/twilio/conference_service.rb
new file mode 100644
index 000000000..5daea8733
--- /dev/null
+++ b/enterprise/app/services/voice/provider/twilio/conference_service.rb
@@ -0,0 +1,46 @@
+class Voice::Provider::Twilio::ConferenceService
+ pattr_initialize [:conversation!, { twilio_client: nil }]
+
+ def ensure_conference_sid
+ existing = conversation.additional_attributes&.dig('conference_sid')
+ return existing if existing.present?
+
+ sid = Voice::Conference::Name.for(conversation)
+ merge_attributes('conference_sid' => sid)
+ sid
+ end
+
+ def mark_agent_joined(user:)
+ merge_attributes(
+ 'agent_joined' => true,
+ 'joined_at' => Time.current.to_i,
+ 'joined_by' => { id: user.id, name: user.name }
+ )
+ end
+
+ def end_conference
+ twilio_client
+ .conferences
+ .list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
+ .each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
+ end
+
+ private
+
+ def merge_attributes(attrs)
+ current = conversation.additional_attributes || {}
+ conversation.update!(additional_attributes: current.merge(attrs))
+ end
+
+ def twilio_client
+ @twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
+ end
+
+ def account_sid
+ @account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid']
+ end
+
+ def auth_token
+ @auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token']
+ end
+end
diff --git a/enterprise/app/services/voice/provider/twilio/token_service.rb b/enterprise/app/services/voice/provider/twilio/token_service.rb
new file mode 100644
index 000000000..cee4c1887
--- /dev/null
+++ b/enterprise/app/services/voice/provider/twilio/token_service.rb
@@ -0,0 +1,62 @@
+class Voice::Provider::Twilio::TokenService
+ pattr_initialize [:inbox!, :user!, :account!]
+
+ def generate
+ {
+ token: access_token.to_jwt,
+ identity: identity,
+ voice_enabled: true,
+ account_sid: config['account_sid'],
+ agent_id: user.id,
+ account_id: account.id,
+ inbox_id: inbox.id,
+ phone_number: inbox.channel.phone_number,
+ twiml_endpoint: twiml_url,
+ has_twiml_app: config['twiml_app_sid'].present?
+ }
+ end
+
+ private
+
+ def config
+ @config ||= inbox.channel.provider_config_hash || {}
+ end
+
+ def identity
+ @identity ||= "agent-#{user.id}-account-#{account.id}"
+ end
+
+ def access_token
+ Twilio::JWT::AccessToken.new(
+ config['account_sid'],
+ config['api_key_sid'],
+ config['api_key_secret'],
+ identity: identity,
+ ttl: 1.hour.to_i
+ ).tap { |token| token.add_grant(voice_grant) }
+ end
+
+ def voice_grant
+ Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
+ grant.incoming_allow = true
+ grant.outgoing_application_sid = config['twiml_app_sid']
+ grant.outgoing_application_params = outgoing_params
+ end
+ end
+
+ def outgoing_params
+ {
+ account_id: account.id,
+ agent_id: user.id,
+ identity: identity,
+ client_name: identity,
+ accountSid: config['account_sid'],
+ is_agent: 'true'
+ }
+ end
+
+ def twiml_url
+ digits = inbox.channel.phone_number.delete_prefix('+')
+ Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
+ end
+end
diff --git a/enterprise/app/services/voice/provider/twilio_adapter.rb b/enterprise/app/services/voice/provider/twilio_adapter.rb
deleted file mode 100644
index 2a73cd960..000000000
--- a/enterprise/app/services/voice/provider/twilio_adapter.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-class Voice::Provider::TwilioAdapter
- def initialize(channel)
- @channel = channel
- end
-
- def initiate_call(to:, _conference_sid: nil, _agent_id: nil)
- cfg = @channel.provider_config_hash
-
- host = ENV.fetch('FRONTEND_URL')
- phone_digits = @channel.phone_number.delete_prefix('+')
- callback_url = "#{host}/twilio/voice/call/#{phone_digits}"
-
- params = {
- from: @channel.phone_number,
- to: to,
- url: callback_url,
- status_callback: "#{host}/twilio/voice/status/#{phone_digits}",
- status_callback_event: %w[initiated ringing answered completed],
- status_callback_method: 'POST'
- }
-
- call = twilio_client(cfg).calls.create(**params)
-
- { call_sid: call.sid }
- end
-
- private
-
- def twilio_client(config)
- Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
- end
-end
diff --git a/package.json b/package.json
index 94b77ffdc..7ba7921b6 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,8 @@
"flag-icons": "^7.2.3",
"floating-vue": "^5.2.2",
"highlight.js": "^11.10.0",
+ "html-to-image": "^1.11.13",
+ "html2canvas": "^1.4.1",
"idb": "^8.0.0",
"js-cookie": "^3.0.5",
"json-logic-js": "^2.0.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f8b1c4b59..7b7d2d889 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -127,6 +127,12 @@ importers:
highlight.js:
specifier: ^11.10.0
version: 11.10.0
+ html-to-image:
+ specifier: ^1.11.13
+ version: 1.11.13
+ html2canvas:
+ specifier: ^1.4.1
+ version: 1.4.1
idb:
specifier: ^8.0.0
version: 8.0.0
@@ -1672,6 +1678,10 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+ base64-arraybuffer@1.0.2:
+ resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
+ engines: {node: '>= 0.6.0'}
+
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -1918,6 +1928,9 @@ packages:
peerDependencies:
postcss: ^8.4
+ css-line-break@2.1.0:
+ resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
+
css-prefers-color-scheme@8.0.2:
resolution: {integrity: sha512-OvFghizHJ45x7nsJJUSYLyQNTzsCU8yWjxAc/nhPQg1pbs18LMoET8N3kOweFDPy0JV0OSXN2iqRFhPBHYOeMA==}
engines: {node: ^14 || ^16 || >=18}
@@ -2686,6 +2699,13 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+ html-to-image@1.11.13:
+ resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
+
+ html2canvas@1.4.1:
+ resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
+ engines: {node: '>=8.0.0'}
+
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
@@ -4214,6 +4234,9 @@ packages:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
+ text-segmentation@1.0.3:
+ resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
+
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@@ -4410,6 +4433,9 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
+ utrie@1.0.2:
+ resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
+
video.js@7.18.1:
resolution: {integrity: sha512-mnXdmkVcD5qQdKMZafDjqdhrnKGettZaGSVkExjACiylSB4r2Yt5W1bchsKmjFpfuNfszsMjTUnnoIWSSqoe/Q==}
@@ -6255,6 +6281,8 @@ snapshots:
balanced-match@1.0.2: {}
+ base64-arraybuffer@1.0.2: {}
+
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -6537,6 +6565,10 @@ snapshots:
postcss-selector-parser: 6.1.1
postcss-value-parser: 4.2.0
+ css-line-break@2.1.0:
+ dependencies:
+ utrie: 1.0.2
+
css-prefers-color-scheme@8.0.2(postcss@8.4.47):
dependencies:
postcss: 8.4.47
@@ -7471,6 +7503,13 @@ snapshots:
html-escaper@2.0.2: {}
+ html-to-image@1.11.13: {}
+
+ html2canvas@1.4.1:
+ dependencies:
+ css-line-break: 2.1.0
+ text-segmentation: 1.0.3
+
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
@@ -9163,6 +9202,10 @@ snapshots:
glob: 10.4.5
minimatch: 9.0.5
+ text-segmentation@1.0.3:
+ dependencies:
+ utrie: 1.0.2
+
text-table@0.2.0: {}
thenify-all@1.6.0:
@@ -9363,6 +9406,10 @@ snapshots:
utils-merge@1.0.1: {}
+ utrie@1.0.2:
+ dependencies:
+ base64-arraybuffer: 1.0.2
+
video.js@7.18.1:
dependencies:
'@babel/runtime': 7.25.6
diff --git a/public/assets/images/dashboard/year-in-review/double-quotes.png b/public/assets/images/dashboard/year-in-review/double-quotes.png
new file mode 100644
index 000000000..252eb7b28
Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/double-quotes.png differ
diff --git a/public/assets/images/dashboard/year-in-review/fifth-frame-signature.png b/public/assets/images/dashboard/year-in-review/fifth-frame-signature.png
new file mode 100644
index 000000000..4febb551e
Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/fifth-frame-signature.png differ
diff --git a/public/assets/images/dashboard/year-in-review/first-frame-bg.png b/public/assets/images/dashboard/year-in-review/first-frame-bg.png
new file mode 100644
index 000000000..515a8cf90
Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/first-frame-bg.png differ
diff --git a/public/assets/images/dashboard/year-in-review/first-frame-candles.png b/public/assets/images/dashboard/year-in-review/first-frame-candles.png
new file mode 100644
index 000000000..9495a80b5
Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/first-frame-candles.png differ
diff --git a/public/assets/images/dashboard/year-in-review/fourth-frame-clock.png b/public/assets/images/dashboard/year-in-review/fourth-frame-clock.png
new file mode 100644
index 000000000..b81f754b5
Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/fourth-frame-clock.png differ
diff --git a/public/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png b/public/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png
new file mode 100644
index 000000000..a422a8c00
Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png differ
diff --git a/public/assets/images/dashboard/year-in-review/third-frame-coffee.png b/public/assets/images/dashboard/year-in-review/third-frame-coffee.png
new file mode 100644
index 000000000..c54fb302f
Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/third-frame-coffee.png differ
diff --git a/public/assets/images/dashboard/year-in-review/year-in-review-sidebar.png b/public/assets/images/dashboard/year-in-review/year-in-review-sidebar.png
new file mode 100644
index 000000000..e087e15d9
Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/year-in-review-sidebar.png differ
diff --git a/public/audio/dashboard/drumroll.mp3 b/public/audio/dashboard/drumroll.mp3
new file mode 100644
index 000000000..5284a9d57
Binary files /dev/null and b/public/audio/dashboard/drumroll.mp3 differ
diff --git a/spec/builders/year_in_review_builder_spec.rb b/spec/builders/year_in_review_builder_spec.rb
new file mode 100644
index 000000000..526b684b5
--- /dev/null
+++ b/spec/builders/year_in_review_builder_spec.rb
@@ -0,0 +1,64 @@
+require 'rails_helper'
+
+RSpec.describe YearInReviewBuilder, type: :model do
+ subject(:builder) { described_class.new(account: account, user_id: user.id, year: year) }
+
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+ let(:year) { 2025 }
+
+ describe '#build' do
+ context 'when there is no data for the year' do
+ it 'returns empty aggregates' do
+ result = builder.build
+
+ expect(result[:year]).to eq(year)
+ expect(result[:total_conversations]).to eq(0)
+ expect(result[:busiest_day]).to be_nil
+ expect(result[:support_personality]).to eq({ avg_response_time_seconds: 0 })
+ end
+ end
+
+ context 'when there is data for the year' do
+ let(:busiest_date) { Time.zone.local(year, 3, 10, 10, 0, 0) }
+ let(:other_date) { Time.zone.local(year, 3, 11, 10, 0, 0) }
+
+ before do
+ create(:conversation, account: account, assignee: user, created_at: busiest_date)
+ create(:conversation, account: account, assignee: user, created_at: busiest_date + 1.hour)
+ create(:conversation, account: account, assignee: user, created_at: other_date)
+
+ create(
+ :reporting_event,
+ account: account,
+ user: user,
+ name: 'first_response',
+ value: 12.7,
+ created_at: busiest_date
+ )
+ end
+
+ it 'returns total conversations count' do
+ expect(builder.build[:total_conversations]).to eq(3)
+ end
+
+ it 'returns busiest day data' do
+ expect(builder.build[:busiest_day]).to eq({ date: busiest_date.strftime('%b %d'), count: 2 })
+ end
+
+ it 'returns support personality data' do
+ expect(builder.build[:support_personality]).to eq({ avg_response_time_seconds: 12 })
+ end
+
+ it 'scopes data to the provided year' do
+ create(:conversation, account: account, assignee: user, created_at: Time.zone.local(year - 1, 6, 1))
+ create(:reporting_event, account: account, user: user, name: 'first_response', value: 99, created_at: Time.zone.local(year - 1, 6, 1))
+
+ result = builder.build
+
+ expect(result[:total_conversations]).to eq(3)
+ expect(result[:support_personality]).to eq({ avg_response_time_seconds: 12 })
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb
new file mode 100644
index 000000000..f4949b9b8
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb
@@ -0,0 +1,142 @@
+require 'rails_helper'
+
+RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
+ let(:account) { create(:account) }
+ let(:voice_channel) { create(:channel_voice, account: account) }
+ let(:voice_inbox) { voice_channel.inbox }
+ let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
+ let(:admin) { create(:user, :administrator, account: account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+
+ let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
+ let(:voice_grant) { instance_double(Twilio::JWT::AccessToken::VoiceGrant) }
+ let(:conference_service) do
+ instance_double(
+ Voice::Provider::Twilio::ConferenceService,
+ ensure_conference_sid: 'CF123',
+ mark_agent_joined: true,
+ end_conference: true
+ )
+ end
+
+ before do
+ allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(Twilio::JWT::AccessToken::VoiceGrant).to receive(:new).and_return(voice_grant)
+ allow(voice_grant).to receive(:outgoing_application_sid=)
+ allow(voice_grant).to receive(:outgoing_application_params=)
+ allow(voice_grant).to receive(:incoming_allow=)
+ allow(Voice::Provider::Twilio::ConferenceService).to receive(:new).and_return(conference_service)
+ end
+
+ describe 'GET /conference/token' do
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference/token"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated agent with inbox access' do
+ before { create(:inbox_member, inbox: voice_inbox, user: agent) }
+
+ it 'returns token payload' do
+ fake_token = instance_double(Twilio::JWT::AccessToken, to_jwt: 'jwt-token', add_grant: nil)
+ allow(Twilio::JWT::AccessToken).to receive(:new).and_return(fake_token)
+
+ get "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference/token",
+ headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ body = response.parsed_body
+ expect(body['token']).to eq('jwt-token')
+ expect(body['account_id']).to eq(account.id)
+ expect(body['inbox_id']).to eq(voice_inbox.id)
+ end
+ end
+ end
+
+ describe 'POST /conference' do
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated agent with inbox access' do
+ before { create(:inbox_member, inbox: voice_inbox, user: agent) }
+
+ it 'creates conference and sets identifier' do
+ post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
+ headers: agent.create_new_auth_token,
+ params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
+
+ expect(response).to have_http_status(:ok)
+ body = response.parsed_body
+ expect(body['conference_sid']).to be_present
+ conversation.reload
+ expect(conversation.identifier).to eq('CALL123')
+ expect(conference_service).to have_received(:ensure_conference_sid)
+ expect(conference_service).to have_received(:mark_agent_joined)
+ end
+
+ it 'does not allow accessing conversations from inboxes without access' do
+ other_inbox = create(:inbox, account: account)
+ other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
+ headers: agent.create_new_auth_token,
+ params: { conversation_id: other_conversation.display_id, call_sid: 'CALL123' }
+
+ expect(response).to have_http_status(:not_found)
+ other_conversation.reload
+ expect(other_conversation.identifier).to be_nil
+ end
+
+ it 'returns conflict when call_sid missing' do
+ post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
+ headers: agent.create_new_auth_token,
+ params: { conversation_id: conversation.display_id }
+
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+ end
+ end
+
+ describe 'DELETE /conference' do
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated agent with inbox access' do
+ before { create(:inbox_member, inbox: voice_inbox, user: agent) }
+
+ it 'ends conference and returns success' do
+ delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
+ headers: agent.create_new_auth_token,
+ params: { conversation_id: conversation.display_id }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['id']).to eq(conversation.display_id)
+ expect(conference_service).to have_received(:end_conference)
+ end
+
+ it 'does not allow ending conferences for conversations from inboxes without access' do
+ other_inbox = create(:inbox, account: account)
+ other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
+
+ delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
+ headers: agent.create_new_auth_token,
+ params: { conversation_id: other_conversation.display_id }
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/models/captain/custom_tool_spec.rb b/spec/enterprise/models/captain/custom_tool_spec.rb
index f936eeaa5..0ead8fb1f 100644
--- a/spec/enterprise/models/captain/custom_tool_spec.rb
+++ b/spec/enterprise/models/captain/custom_tool_spec.rb
@@ -102,8 +102,9 @@ RSpec.describe Captain::CustomTool, type: :model do
enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
- expect(described_class.enabled).to include(enabled_tool)
- expect(described_class.enabled).not_to include(disabled_tool)
+ enabled_ids = described_class.enabled.pluck(:id)
+ expect(enabled_ids).to include(enabled_tool.id)
+ expect(enabled_ids).not_to include(disabled_tool.id)
end
end
end
diff --git a/spec/enterprise/services/voice/provider/twilio/adapter_spec.rb b/spec/enterprise/services/voice/provider/twilio/adapter_spec.rb
new file mode 100644
index 000000000..68157ac50
--- /dev/null
+++ b/spec/enterprise/services/voice/provider/twilio/adapter_spec.rb
@@ -0,0 +1,43 @@
+require 'rails_helper'
+
+describe Voice::Provider::Twilio::Adapter do
+ let(:account) { create(:account) }
+ let(:channel) { create(:channel_voice, account: account) }
+ let(:adapter) { described_class.new(channel) }
+ let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
+ let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) }
+ let(:call_instance) do
+ instance_double(Twilio::REST::Api::V2010::AccountContext::CallInstance, sid: 'CA123', status: 'queued')
+ end
+ let(:client_double) { instance_double(Twilio::REST::Client, calls: calls_double) }
+
+ before do
+ allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
+ end
+
+ it 'initiates an outbound call with expected params' do
+ allow(calls_double).to receive(:create).and_return(call_instance)
+
+ allow(Twilio::REST::Client).to receive(:new)
+ .with(channel.provider_config_hash['account_sid'], channel.provider_config_hash['auth_token'])
+ .and_return(client_double)
+
+ result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42)
+ phone_digits = channel.phone_number.delete_prefix('+')
+ expected_url = Rails.application.routes.url_helpers.twilio_voice_call_url(phone: phone_digits)
+ expected_status_callback = Rails.application.routes.url_helpers.twilio_voice_status_url(phone: phone_digits)
+
+ expect(calls_double).to have_received(:create).with(hash_including(
+ from: channel.phone_number,
+ to: '+15550001111',
+ url: expected_url,
+ status_callback: expected_status_callback,
+ status_callback_event: array_including('completed', 'failed', 'busy', 'no-answer',
+ 'canceled')
+ ))
+ expect(result[:call_sid]).to eq('CA123')
+ expect(result[:conference_sid]).to eq('CF999')
+ expect(result[:agent_id]).to eq(42)
+ expect(result[:call_direction]).to eq('outbound')
+ end
+end
diff --git a/spec/enterprise/services/voice/provider/twilio/conference_service_spec.rb b/spec/enterprise/services/voice/provider/twilio/conference_service_spec.rb
new file mode 100644
index 000000000..9997280cb
--- /dev/null
+++ b/spec/enterprise/services/voice/provider/twilio/conference_service_spec.rb
@@ -0,0 +1,60 @@
+require 'rails_helper'
+
+describe Voice::Provider::Twilio::ConferenceService do
+ let(:account) { create(:account) }
+ let(:channel) { create(:channel_voice, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
+ let(:twilio_client) { instance_double(Twilio::REST::Client) }
+ let(:service) { described_class.new(conversation: conversation, twilio_client: twilio_client) }
+ let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
+
+ before do
+ allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
+ end
+
+ describe '#ensure_conference_sid' do
+ it 'returns existing sid if present' do
+ conversation.update!(additional_attributes: { 'conference_sid' => 'CF_EXISTING' })
+
+ expect(service.ensure_conference_sid).to eq('CF_EXISTING')
+ end
+
+ it 'sets and returns generated sid when missing' do
+ allow(Voice::Conference::Name).to receive(:for).and_return('CF_GEN')
+
+ sid = service.ensure_conference_sid
+
+ expect(sid).to eq('CF_GEN')
+ expect(conversation.reload.additional_attributes['conference_sid']).to eq('CF_GEN')
+ end
+ end
+
+ describe '#mark_agent_joined' do
+ it 'stores agent join metadata' do
+ agent = create(:user, account: account)
+
+ service.mark_agent_joined(user: agent)
+
+ attrs = conversation.reload.additional_attributes
+ expect(attrs['agent_joined']).to be true
+ expect(attrs['joined_by']['id']).to eq(agent.id)
+ end
+ end
+
+ describe '#end_conference' do
+ it 'completes in-progress conferences' do
+ conferences_proxy = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceList)
+ conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123')
+ conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance)
+
+ allow(twilio_client).to receive(:conferences).with(no_args).and_return(conferences_proxy)
+ allow(conferences_proxy).to receive(:list).and_return([conf_instance])
+ allow(twilio_client).to receive(:conferences).with('CF123').and_return(conf_context)
+ allow(conf_context).to receive(:update).with(status: 'completed')
+
+ service.end_conference
+
+ expect(conf_context).to have_received(:update).with(status: 'completed')
+ end
+ end
+end
diff --git a/spec/enterprise/services/voice/provider/twilio/token_service_spec.rb b/spec/enterprise/services/voice/provider/twilio/token_service_spec.rb
new file mode 100644
index 000000000..fe6aebe01
--- /dev/null
+++ b/spec/enterprise/services/voice/provider/twilio/token_service_spec.rb
@@ -0,0 +1,33 @@
+require 'rails_helper'
+
+describe Voice::Provider::Twilio::TokenService do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, :administrator, account: account) }
+ let(:voice_channel) { create(:channel_voice, account: account) }
+ let(:inbox) { voice_channel.inbox }
+
+ let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
+ let(:voice_grant) { instance_double(Twilio::JWT::AccessToken::VoiceGrant) }
+
+ before do
+ allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(Twilio::JWT::AccessToken::VoiceGrant).to receive(:new).and_return(voice_grant)
+ allow(voice_grant).to receive(:outgoing_application_sid=)
+ allow(voice_grant).to receive(:outgoing_application_params=)
+ allow(voice_grant).to receive(:incoming_allow=)
+ end
+
+ it 'returns a token payload with expected keys' do
+ fake_token = instance_double(Twilio::JWT::AccessToken, to_jwt: 'jwt-token', add_grant: nil)
+ allow(Twilio::JWT::AccessToken).to receive(:new).and_return(fake_token)
+
+ payload = described_class.new(inbox: inbox, user: user, account: account).generate
+
+ expect(payload[:token]).to eq('jwt-token')
+ expect(payload[:identity]).to include("agent-#{user.id}")
+ expect(payload[:inbox_id]).to eq(inbox.id)
+ expect(payload[:account_id]).to eq(account.id)
+ expect(payload[:voice_enabled]).to be true
+ expect(payload[:twiml_endpoint]).to include(voice_channel.phone_number.delete_prefix('+'))
+ end
+end
diff --git a/tailwind.config.js b/tailwind.config.js
index 647a3e871..90568e4f5 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -41,7 +41,7 @@ const tailwindConfig = {
fontFamily: {
sans: defaultSansFonts,
inter: ['Inter', ...defaultSansFonts],
- interDisplay: ['Inter Display', ...defaultSansFonts],
+ interDisplay: ['InterDisplay', ...defaultSansFonts],
},
fontWeight: {
420: '420',