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/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 773126755..57062a5b2 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -92,7 +92,8 @@ class Api::V1::AccountsController < Api::BaseController end def settings_params - params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label) + params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label, + conversation_required_attributes: []) end def check_signup_enabled 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/ConversationWorkflow/AttributeListItem.vue b/app/javascript/dashboard/components-next/ConversationWorkflow/AttributeListItem.vue new file mode 100644 index 000000000..f5822f67d --- /dev/null +++ b/app/javascript/dashboard/components-next/ConversationWorkflow/AttributeListItem.vue @@ -0,0 +1,88 @@ + + + diff --git a/app/javascript/dashboard/components-next/CustomAttributes/AttributeBadge.vue b/app/javascript/dashboard/components-next/CustomAttributes/AttributeBadge.vue new file mode 100644 index 000000000..fb0c9867a --- /dev/null +++ b/app/javascript/dashboard/components-next/CustomAttributes/AttributeBadge.vue @@ -0,0 +1,42 @@ + + + diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue index fa9102d59..ee71bf51a 100644 --- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue +++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue @@ -9,6 +9,8 @@ import { useAlert } from 'dashboard/composables'; import { ExceptionWithMessage } from 'shared/helpers/CustomErrors'; import { debounce } from '@chatwoot/utils'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; +import { emitter } from 'shared/helpers/mitt'; +import { BUS_EVENTS } from 'shared/constants/busEvents'; import { searchContacts, createNewContact, @@ -226,6 +228,8 @@ const keyboardEvents = { action: () => { if (showComposeNewConversation.value) { showComposeNewConversation.value = false; + emit('close'); + emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false); } }, }, diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue index cb1f9d99d..c2971c60d 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'; import { useUISettings } from 'dashboard/composables/useUISettings'; import { useFileUpload } from 'dashboard/composables/useFileUpload'; import { vOnClickOutside } from '@vueuse/components'; +import { useEventListener } from '@vueuse/core'; import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import FileUpload from 'vue-upload-component'; @@ -163,6 +164,20 @@ const keyboardEvents = { }, }; useKeyboardEvents(keyboardEvents); + +const onPaste = e => { + if (!props.isEmailOrWebWidgetInbox) return; + + const files = e.clipboardData?.files; + if (!files?.length) return; + + Array.from(files).forEach(file => { + const { name, type, size } = file; + onFileUpload({ file, name, type, size }); + }); +}; + +useEventListener(document, 'paste', onPaste); - { :on-close="hideAddPopup" :selected-attribute-model-tab="selectedTabIndex" /> + + + + diff --git a/app/javascript/entrypoints/sdk.js b/app/javascript/entrypoints/sdk.js index 2a919f42c..6e1639b1c 100755 --- a/app/javascript/entrypoints/sdk.js +++ b/app/javascript/entrypoints/sdk.js @@ -76,7 +76,7 @@ const runSDK = ({ baseUrl, websiteToken }) => { welcomeDescription: chatwootSettings.welcomeDescription || '', availableMessage: chatwootSettings.availableMessage || '', unavailableMessage: chatwootSettings.unavailableMessage || '', - enableFileUpload: chatwootSettings.enableFileUpload ?? true, + enableFileUpload: chatwootSettings.enableFileUpload, enableEmojiPicker: chatwootSettings.enableEmojiPicker ?? true, enableEndConversation: chatwootSettings.enableEndConversation ?? true, diff --git a/app/javascript/widget/components/ChatAttachment.vue b/app/javascript/widget/components/ChatAttachment.vue index 169749527..64c17e9c6 100755 --- a/app/javascript/widget/components/ChatAttachment.vue +++ b/app/javascript/widget/components/ChatAttachment.vue @@ -11,6 +11,7 @@ import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import { DirectUpload } from 'activestorage'; import { mapGetters } from 'vuex'; import { emitter } from 'shared/helpers/mitt'; +import { useAttachments } from '../composables/useAttachments'; export default { components: { FluentIcon, FileUpload, Spinner }, @@ -20,13 +21,16 @@ export default { default: () => {}, }, }, + setup() { + const { canHandleAttachments } = useAttachments(); + return { canHandleAttachments }; + }, data() { return { isUploading: false }; }, computed: { ...mapGetters({ globalConfig: 'globalConfig/get', - shouldShowFilePicker: 'appConfig/getShouldShowFilePicker', }), fileUploadSizeLimit() { return resolveMaximumFileUploadSize( @@ -46,7 +50,7 @@ export default { methods: { handleClipboardPaste(e) { // If file picker is not enabled, do not allow paste - if (!this.shouldShowFilePicker) return; + if (!this.canHandleAttachments) return; const items = (e.clipboardData || e.originalEvent.clipboardData).items; // items is a DataTransferItemList object which does not have forEach method diff --git a/app/javascript/widget/components/ChatInputWrap.vue b/app/javascript/widget/components/ChatInputWrap.vue index c423ab220..ce8d17455 100755 --- a/app/javascript/widget/components/ChatInputWrap.vue +++ b/app/javascript/widget/components/ChatInputWrap.vue @@ -3,7 +3,7 @@ import { mapGetters } from 'vuex'; import ChatAttachmentButton from 'widget/components/ChatAttachment.vue'; import ChatSendButton from 'widget/components/ChatSendButton.vue'; -import configMixin from '../mixins/configMixin'; +import { useAttachments } from '../composables/useAttachments'; import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import ResizableTextArea from 'shared/components/ResizableTextArea.vue'; @@ -18,7 +18,6 @@ export default { FluentIcon, ResizableTextArea, }, - mixins: [configMixin], props: { onSendMessage: { type: Function, @@ -29,6 +28,18 @@ export default { default: () => {}, }, }, + setup() { + const { + canHandleAttachments, + shouldShowEmojiPicker, + hasEmojiPickerEnabled, + } = useAttachments(); + return { + canHandleAttachments, + shouldShowEmojiPicker, + hasEmojiPickerEnabled, + }; + }, data() { return { userInput: '', @@ -41,15 +52,10 @@ export default { ...mapGetters({ widgetColor: 'appConfig/getWidgetColor', isWidgetOpen: 'appConfig/getIsWidgetOpen', - shouldShowFilePicker: 'appConfig/getShouldShowFilePicker', shouldShowEmojiPicker: 'appConfig/getShouldShowEmojiPicker', }), showAttachment() { - return ( - this.shouldShowFilePicker && - this.hasAttachmentsEnabled && - this.userInput.length === 0 - ); + return this.canHandleAttachments && this.userInput.length === 0; }, showSendButton() { return this.userInput.length > 0; diff --git a/app/javascript/widget/composables/specs/useAttachments.spec.js b/app/javascript/widget/composables/specs/useAttachments.spec.js new file mode 100644 index 000000000..578fd3ae8 --- /dev/null +++ b/app/javascript/widget/composables/specs/useAttachments.spec.js @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useAttachments } from '../useAttachments'; +import { useStore } from 'vuex'; +import { computed } from 'vue'; + +// Mock Vue's useStore +vi.mock('vuex', () => ({ + useStore: vi.fn(), +})); + +// Mock Vue's computed +vi.mock('vue', () => ({ + computed: vi.fn(fn => ({ value: fn() })), +})); + +describe('useAttachments', () => { + let mockStore; + let mockGetters; + + beforeEach(() => { + // Reset window.chatwootWebChannel + delete window.chatwootWebChannel; + + // Create mock store + mockGetters = {}; + mockStore = { + getters: mockGetters, + }; + vi.mocked(useStore).mockReturnValue(mockStore); + + // Mock computed to return a reactive-like object + vi.mocked(computed).mockImplementation(fn => ({ value: fn() })); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('shouldShowFilePicker', () => { + it('returns value from store getter', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = true; + + const { shouldShowFilePicker } = useAttachments(); + + expect(shouldShowFilePicker.value).toBe(true); + }); + + it('returns undefined when not set in store', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = undefined; + + const { shouldShowFilePicker } = useAttachments(); + + expect(shouldShowFilePicker.value).toBeUndefined(); + }); + }); + + describe('hasAttachmentsEnabled', () => { + it('returns true when attachments are enabled in channel config', () => { + window.chatwootWebChannel = { + enabledFeatures: ['attachments', 'emoji'], + }; + + const { hasAttachmentsEnabled } = useAttachments(); + + expect(hasAttachmentsEnabled.value).toBe(true); + }); + + it('returns false when attachments are not enabled in channel config', () => { + window.chatwootWebChannel = { + enabledFeatures: ['emoji'], + }; + + const { hasAttachmentsEnabled } = useAttachments(); + + expect(hasAttachmentsEnabled.value).toBe(false); + }); + + it('returns false when channel config has no enabled features', () => { + window.chatwootWebChannel = { + enabledFeatures: [], + }; + + const { hasAttachmentsEnabled } = useAttachments(); + + expect(hasAttachmentsEnabled.value).toBe(false); + }); + + it('returns false when channel config is missing', () => { + window.chatwootWebChannel = undefined; + + const { hasAttachmentsEnabled } = useAttachments(); + + expect(hasAttachmentsEnabled.value).toBe(false); + }); + + it('returns false when enabledFeatures is missing', () => { + window.chatwootWebChannel = {}; + + const { hasAttachmentsEnabled } = useAttachments(); + + expect(hasAttachmentsEnabled.value).toBe(false); + }); + }); + + describe('canHandleAttachments', () => { + beforeEach(() => { + // Set up a default channel config + window.chatwootWebChannel = { + enabledFeatures: ['attachments'], + }; + }); + + it('prioritizes SDK flag when explicitly set to true', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = true; + + const { canHandleAttachments } = useAttachments(); + + expect(canHandleAttachments.value).toBe(true); + }); + + it('prioritizes SDK flag when explicitly set to false', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = false; + + const { canHandleAttachments } = useAttachments(); + + expect(canHandleAttachments.value).toBe(false); + }); + + it('falls back to inbox settings when SDK flag is undefined', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = undefined; + window.chatwootWebChannel = { + enabledFeatures: ['attachments'], + }; + + const { canHandleAttachments } = useAttachments(); + + expect(canHandleAttachments.value).toBe(true); + }); + + it('falls back to inbox settings when SDK flag is undefined and attachments disabled', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = undefined; + window.chatwootWebChannel = { + enabledFeatures: ['emoji'], + }; + + const { canHandleAttachments } = useAttachments(); + + expect(canHandleAttachments.value).toBe(false); + }); + + it('prioritizes SDK false over inbox settings true', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = false; + window.chatwootWebChannel = { + enabledFeatures: ['attachments'], + }; + + const { canHandleAttachments } = useAttachments(); + + expect(canHandleAttachments.value).toBe(false); + }); + + it('prioritizes SDK true over inbox settings false', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = true; + window.chatwootWebChannel = { + enabledFeatures: ['emoji'], // no attachments + }; + + const { canHandleAttachments } = useAttachments(); + + expect(canHandleAttachments.value).toBe(true); + }); + }); + + describe('hasEmojiPickerEnabled', () => { + it('returns true when emoji picker is enabled in channel config', () => { + window.chatwootWebChannel = { + enabledFeatures: ['emoji_picker', 'attachments'], + }; + + const { hasEmojiPickerEnabled } = useAttachments(); + + expect(hasEmojiPickerEnabled.value).toBe(true); + }); + + it('returns false when emoji picker is not enabled in channel config', () => { + window.chatwootWebChannel = { + enabledFeatures: ['attachments'], + }; + + const { hasEmojiPickerEnabled } = useAttachments(); + + expect(hasEmojiPickerEnabled.value).toBe(false); + }); + }); + + describe('shouldShowEmojiPicker', () => { + it('returns value from store getter', () => { + mockGetters['appConfig/getShouldShowEmojiPicker'] = true; + + const { shouldShowEmojiPicker } = useAttachments(); + + expect(shouldShowEmojiPicker.value).toBe(true); + }); + }); + + describe('integration test', () => { + it('returns all expected properties', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = undefined; + mockGetters['appConfig/getShouldShowEmojiPicker'] = true; + window.chatwootWebChannel = { + enabledFeatures: ['attachments', 'emoji_picker'], + }; + + const result = useAttachments(); + + expect(result).toHaveProperty('shouldShowFilePicker'); + expect(result).toHaveProperty('shouldShowEmojiPicker'); + expect(result).toHaveProperty('hasAttachmentsEnabled'); + expect(result).toHaveProperty('hasEmojiPickerEnabled'); + expect(result).toHaveProperty('canHandleAttachments'); + expect(Object.keys(result)).toHaveLength(5); + }); + }); +}); diff --git a/app/javascript/widget/composables/useAttachments.js b/app/javascript/widget/composables/useAttachments.js new file mode 100644 index 000000000..5ffab60a6 --- /dev/null +++ b/app/javascript/widget/composables/useAttachments.js @@ -0,0 +1,42 @@ +import { computed } from 'vue'; +import { useStore } from 'vuex'; + +export function useAttachments() { + const store = useStore(); + + const shouldShowFilePicker = computed( + () => store.getters['appConfig/getShouldShowFilePicker'] + ); + + const shouldShowEmojiPicker = computed( + () => store.getters['appConfig/getShouldShowEmojiPicker'] + ); + + const hasAttachmentsEnabled = computed(() => { + const channelConfig = window.chatwootWebChannel; + return channelConfig?.enabledFeatures?.includes('attachments') || false; + }); + + const hasEmojiPickerEnabled = computed(() => { + const channelConfig = window.chatwootWebChannel; + return channelConfig?.enabledFeatures?.includes('emoji_picker') || false; + }); + + const canHandleAttachments = computed(() => { + // If enableFileUpload was explicitly set via SDK, prioritize that + if (shouldShowFilePicker.value !== undefined) { + return shouldShowFilePicker.value; + } + + // Otherwise, fall back to inbox settings only + return hasAttachmentsEnabled.value; + }); + + return { + shouldShowFilePicker, + shouldShowEmojiPicker, + hasAttachmentsEnabled, + hasEmojiPickerEnabled, + canHandleAttachments, + }; +} diff --git a/app/javascript/widget/store/modules/appConfig.js b/app/javascript/widget/store/modules/appConfig.js index 3ad5078b8..5b720d907 100644 --- a/app/javascript/widget/store/modules/appConfig.js +++ b/app/javascript/widget/store/modules/appConfig.js @@ -25,7 +25,7 @@ const state = { welcomeDescription: '', availableMessage: '', unavailableMessage: '', - enableFileUpload: true, + enableFileUpload: undefined, enableEmojiPicker: true, enableEndConversation: true, }; @@ -64,7 +64,7 @@ export const actions = { welcomeDescription = '', availableMessage = '', unavailableMessage = '', - enableFileUpload = true, + enableFileUpload = undefined, enableEmojiPicker = true, enableEndConversation = true, } diff --git a/app/models/account.rb b/app/models/account.rb index 06767939d..bcfebf39a 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -37,7 +37,11 @@ class Account < ApplicationRecord 'auto_resolve_message': { 'type': %w[string null] }, 'auto_resolve_ignore_waiting': { 'type': %w[boolean null] }, 'audio_transcriptions': { 'type': %w[boolean null] }, - 'auto_resolve_label': { 'type': %w[string null] } + 'auto_resolve_label': { 'type': %w[string null] }, + 'conversation_required_attributes': { + 'type': %w[array null], + 'items': { 'type': 'string' } + } }, 'required': [], 'additionalProperties': true @@ -55,7 +59,7 @@ class Account < ApplicationRecord attribute_resolver: ->(record) { record.settings } store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting - store_accessor :settings, :audio_transcriptions, :auto_resolve_label + store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes has_many :account_users, dependent: :destroy_async has_many :agent_bot_inboxes, dependent: :destroy_async diff --git a/app/models/custom_attribute_definition.rb b/app/models/custom_attribute_definition.rb index 09d415401..4154da3b8 100644 --- a/app/models/custom_attribute_definition.rb +++ b/app/models/custom_attribute_definition.rb @@ -45,6 +45,7 @@ class CustomAttributeDefinition < ApplicationRecord belongs_to :account after_update :update_widget_pre_chat_custom_fields after_destroy :sync_widget_pre_chat_custom_fields + after_destroy :cleanup_conversation_required_attributes private @@ -56,6 +57,13 @@ class CustomAttributeDefinition < ApplicationRecord ::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self) end + def cleanup_conversation_required_attributes + return unless conversation_attribute? && account.conversation_required_attributes&.include?(attribute_key) + + account.conversation_required_attributes = account.conversation_required_attributes - [attribute_key] + account.save! + end + def attribute_must_not_conflict model_keys = attribute_model.to_sym == :conversation_attribute ? :conversation : :contact return unless attribute_key.in?(STANDARD_ATTRIBUTES[model_keys]) 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 84c266880..1abe4f375 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 602a3c5c7..28b82df6f 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==} @@ -4217,6 +4237,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==} @@ -4413,6 +4436,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==} @@ -6248,6 +6274,8 @@ snapshots: balanced-match@1.0.2: {} + base64-arraybuffer@1.0.2: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -6530,6 +6558,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 @@ -7464,6 +7496,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 @@ -9158,6 +9197,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: @@ -9358,6 +9401,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/spec/models/custom_attribute_definition_spec.rb b/spec/models/custom_attribute_definition_spec.rb new file mode 100644 index 000000000..1359c2967 --- /dev/null +++ b/spec/models/custom_attribute_definition_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CustomAttributeDefinition do + describe 'callbacks' do + describe '#cleanup_conversation_required_attributes' do + let(:account) { create(:account) } + let(:attribute_key) { 'test_attribute' } + let!(:custom_attribute) do + create(:custom_attribute_definition, + account: account, + attribute_key: attribute_key, + attribute_model: 'conversation_attribute') + end + + context 'when conversation attribute is in required attributes list' do + before do + account.update!(conversation_required_attributes: [attribute_key, 'other_attribute']) + end + + it 'removes the attribute from conversation_required_attributes when destroyed' do + expect { custom_attribute.destroy! } + .to change { account.reload.conversation_required_attributes } + .from([attribute_key, 'other_attribute']) + .to(['other_attribute']) + end + end + + context 'when attribute is contact_attribute' do + let!(:contact_attribute) do + create(:custom_attribute_definition, + account: account, + attribute_key: attribute_key, + attribute_model: 'contact_attribute') + end + + before do + account.update!(conversation_required_attributes: [attribute_key]) + end + + it 'does not modify conversation_required_attributes when destroyed' do + expect { contact_attribute.destroy! } + .not_to(change { account.reload.conversation_required_attributes }) + end + end + end + end +end diff --git a/tailwind.config.js b/tailwind.config.js index 18bd54948..5ac5c5836 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], }, typography: { bubble: {