diff --git a/Gemfile.lock b/Gemfile.lock index 15ed841ac..0a669b606 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -140,24 +140,27 @@ GEM actionmailbox (>= 7.1.0) aws-sdk-s3 (~> 1, >= 1.123.0) aws-sdk-sns (~> 1, >= 1.61.0) - aws-eventstream (1.2.0) - aws-partitions (1.760.0) - aws-sdk-core (3.188.0) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.5) + aws-eventstream (1.4.0) + aws-partitions (1.1198.0) + aws-sdk-core (3.240.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.64.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.126.0) - aws-sdk-core (~> 3, >= 3.174.0) + logger + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.208.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) + aws-sigv4 (~> 1.5) aws-sdk-sns (1.70.0) aws-sdk-core (~> 3, >= 3.188.0) aws-sigv4 (~> 1.1) - aws-sigv4 (1.5.2) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) barnes (0.0.9) multi_json (~> 1) diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 3d894808d..0840d0eea 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -1,4 +1,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController + include AttachmentConcern + before_action :check_authorization before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone] @@ -9,25 +11,32 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont def show; end def create + blobs, actions, error = validate_and_prepare_attachments(params[:actions]) + return render_could_not_create_error(error) if error + @automation_rule = Current.account.automation_rules.new(automation_rules_permit) - @automation_rule.actions = params[:actions] + @automation_rule.actions = actions @automation_rule.conditions = params[:conditions] - render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid? + return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid? @automation_rule.save! - process_attachments - @automation_rule + blobs.each { |blob| @automation_rule.files.attach(blob) } end def update - ActiveRecord::Base.transaction do - automation_rule_update - process_attachments + blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule) + return render_could_not_create_error(error) if error + ActiveRecord::Base.transaction do + @automation_rule.assign_attributes(automation_rules_permit) + @automation_rule.actions = actions if params[:actions] + @automation_rule.conditions = params[:conditions] if params[:conditions] + @automation_rule.save! + blobs.each { |blob| @automation_rule.files.attach(blob) } rescue StandardError => e Rails.logger.error e - render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity + render_could_not_create_error(@automation_rule.errors.messages) end end @@ -43,29 +52,11 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont @automation_rule = new_rule end - def process_attachments - actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' } - return if actions.blank? - - actions.each do |action| - blob_id = action['action_params'] - blob = ActiveStorage::Blob.find_by(id: blob_id) - @automation_rule.files.attach(blob) - end - end - private - def automation_rule_update - @automation_rule.update!(automation_rules_permit) - @automation_rule.actions = params[:actions] if params[:actions] - @automation_rule.conditions = params[:conditions] if params[:conditions] - @automation_rule.save! - end - def automation_rules_permit params.permit( - :name, :description, :event_name, :account_id, :active, + :name, :description, :event_name, :active, conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }], actions: [:action_name, { action_params: [] }] ) diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb index 58ec3bfca..f3b14d49f 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: def permitted_params params.require(:twilio_channel).permit( - :account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid + :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid ) end end diff --git a/app/controllers/api/v1/accounts/macros_controller.rb b/app/controllers/api/v1/accounts/macros_controller.rb index 5dcdd2023..c4e0cd6dd 100644 --- a/app/controllers/api/v1/accounts/macros_controller.rb +++ b/app/controllers/api/v1/accounts/macros_controller.rb @@ -1,4 +1,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController + include AttachmentConcern + before_action :fetch_macro, only: [:show, :update, :destroy, :execute] before_action :check_authorization, only: [:show, :update, :destroy, :execute] @@ -11,26 +13,32 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController end def create + blobs, actions, error = validate_and_prepare_attachments(params[:actions]) + return render_could_not_create_error(error) if error + @macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id)) @macro.set_visibility(current_user, permitted_params) - @macro.actions = params[:actions] + @macro.actions = actions - render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid? + return render_could_not_create_error(@macro.errors.messages) unless @macro.valid? @macro.save! - process_attachments - @macro + blobs.each { |blob| @macro.files.attach(blob) } end def update + blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro) + return render_could_not_create_error(error) if error + ActiveRecord::Base.transaction do - @macro.update!(macros_with_user) + @macro.assign_attributes(macros_with_user) @macro.set_visibility(current_user, permitted_params) - process_attachments + @macro.actions = actions if params[:actions] @macro.save! + blobs.each { |blob| @macro.files.attach(blob) } rescue StandardError => e Rails.logger.error e - render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity + render_could_not_create_error(@macro.errors.messages) end end @@ -47,20 +55,9 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController private - def process_attachments - actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' } - return if actions.blank? - - actions.each do |action| - blob_id = action['action_params'] - blob = ActiveStorage::Blob.find_by(id: blob_id) - @macro.files.attach(blob) - end - end - def permitted_params params.permit( - :name, :account_id, :visibility, + :name, :visibility, actions: [:action_name, { action_params: [] }] ) end diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 57344cc1e..8eb24b757 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def process_attached_logo blob_id = params[:blob_id] - blob = ActiveStorage::Blob.find_by(id: blob_id) + blob = ActiveStorage::Blob.find_signed(blob_id) @portal.logo.attach(blob) end @@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def portal_params params.require(:portal).permit( - :id, :account_id, :color, :custom_domain, :header_text, :homepage_link, + :id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } ) end diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb index 6530279da..479d8ae1b 100644 --- a/app/controllers/api/v1/accounts/upload_controller.rb +++ b/app/controllers/api/v1/accounts/upload_controller.rb @@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController end def render_success(file_blob) - render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id } + render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id } end def render_error(message, status) diff --git a/app/controllers/concerns/attachment_concern.rb b/app/controllers/concerns/attachment_concern.rb new file mode 100644 index 000000000..2652f04be --- /dev/null +++ b/app/controllers/concerns/attachment_concern.rb @@ -0,0 +1,35 @@ +module AttachmentConcern + extend ActiveSupport::Concern + + def validate_and_prepare_attachments(actions, record = nil) + blobs = [] + return [blobs, actions, nil] if actions.blank? + + sanitized = actions.map do |action| + next action unless action[:action_name] == 'send_attachment' + + result = process_attachment_action(action, record, blobs) + return [nil, nil, I18n.t('errors.attachments.invalid')] unless result + + result + end + + [blobs, sanitized, nil] + end + + private + + def process_attachment_action(action, record, blobs) + blob_id = action[:action_params].first + blob = ActiveStorage::Blob.find_signed(blob_id.to_s) + + return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present? + return action if blob_already_attached?(record, blob_id) + + nil + end + + def blob_already_attached?(record, blob_id) + record&.files&.any? { |f| f.blob_id == blob_id.to_i } + end +end diff --git a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js new file mode 100644 index 000000000..67f74a171 --- /dev/null +++ b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js @@ -0,0 +1,95 @@ +import { Device } from '@twilio/voice-sdk'; +import VoiceAPI from './voiceAPIClient'; + +const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected'); + +class TwilioVoiceClient extends EventTarget { + constructor() { + super(); + this.device = null; + this.activeConnection = null; + this.initialized = false; + this.inboxId = null; + } + + async initializeDevice(inboxId) { + this.destroyDevice(); + + const response = await VoiceAPI.getToken(inboxId); + const { token, account_id } = response || {}; + if (!token) throw new Error('Invalid token'); + + this.device = new Device(token, { + allowIncomingWhileBusy: true, + disableAudioContextSounds: true, + appParams: { account_id }, + }); + + this.device.removeAllListeners(); + this.device.on('connect', conn => { + this.activeConnection = conn; + conn.on('disconnect', this.onDisconnect); + }); + + this.device.on('disconnect', this.onDisconnect); + + this.device.on('tokenWillExpire', async () => { + const r = await VoiceAPI.getToken(this.inboxId); + if (r?.token) this.device.updateToken(r.token); + }); + + this.initialized = true; + this.inboxId = inboxId; + + return this.device; + } + + get hasActiveConnection() { + return !!this.activeConnection; + } + + endClientCall() { + if (this.activeConnection) { + this.activeConnection.disconnect(); + } + this.activeConnection = null; + if (this.device) { + this.device.disconnectAll(); + } + } + + destroyDevice() { + if (this.device) { + this.device.destroy(); + } + this.activeConnection = null; + this.device = null; + this.initialized = false; + this.inboxId = null; + } + + async joinClientCall({ to, conversationId }) { + if (!this.device || !this.initialized || !to) return null; + if (this.activeConnection) return this.activeConnection; + + const params = { + To: to, + is_agent: 'true', + conversation_id: conversationId, + }; + + const connection = await this.device.connect({ params }); + this.activeConnection = connection; + + connection.on('disconnect', this.onDisconnect); + + return connection; + } + + onDisconnect = () => { + this.activeConnection = null; + this.dispatchEvent(createCallDisconnectedEvent()); + }; +} + +export default new TwilioVoiceClient(); diff --git a/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js new file mode 100644 index 000000000..6e1e548c8 --- /dev/null +++ b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js @@ -0,0 +1,40 @@ +/* global axios */ +import ApiClient from '../../ApiClient'; +import ContactsAPI from '../../contacts'; + +class VoiceAPI extends ApiClient { + constructor() { + super('voice', { accountScoped: true }); + } + + // eslint-disable-next-line class-methods-use-this + initiateCall(contactId, inboxId) { + return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data); + } + + leaveConference(inboxId, conversationId) { + return axios + .delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { + params: { conversation_id: conversationId }, + }) + .then(r => r.data); + } + + joinConference({ conversationId, inboxId, callSid }) { + return axios + .post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { + conversation_id: conversationId, + call_sid: callSid, + }) + .then(r => r.data); + } + + getToken(inboxId) { + if (!inboxId) return Promise.reject(new Error('Inbox ID is required')); + return axios + .get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`) + .then(r => r.data); + } +} + +export default new VoiceAPI(); diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index 85738d9de..e9183ce9b 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -6,6 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store'; import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { useAlert } from 'dashboard/composables'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; +import { useCallsStore } from 'dashboard/stores/calls'; import Button from 'dashboard/components-next/button/Button.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; @@ -67,6 +68,17 @@ const startCall = async inboxId => { contactId: props.contactId, inboxId, }); + const { call_sid: callSid, conversation_id: conversationId } = response; + + // Add call to store immediately so widget shows + const callsStore = useCallsStore(); + callsStore.addCall({ + callSid, + conversationId, + inboxId, + callDirection: 'outbound', + }); + useAlert(t('CONTACT_PANEL.CALL_INITIATED')); navigateToConversation(response?.conversation_id); } catch (error) { diff --git a/app/javascript/dashboard/components-next/button/Button.vue b/app/javascript/dashboard/components-next/button/Button.vue index 1dfe488e9..b3c950053 100644 --- a/app/javascript/dashboard/components-next/button/Button.vue +++ b/app/javascript/dashboard/components-next/button/Button.vue @@ -146,7 +146,7 @@ const STYLE_CONFIG = { solid: 'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent', faded: - 'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent', + 'bg-n-teal-9/10 text-n-teal-11 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent', outline: 'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9', link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent', diff --git a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue new file mode 100644 index 000000000..4515b5c33 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue @@ -0,0 +1,184 @@ + + + diff --git a/app/javascript/dashboard/composables/useCallSession.js b/app/javascript/dashboard/composables/useCallSession.js new file mode 100644 index 000000000..f58d784b8 --- /dev/null +++ b/app/javascript/dashboard/composables/useCallSession.js @@ -0,0 +1,110 @@ +import { computed, ref, watch, onUnmounted, onMounted } from 'vue'; +import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient'; +import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient'; +import { useCallsStore } from 'dashboard/stores/calls'; +import Timer from 'dashboard/helper/Timer'; + +export function useCallSession() { + const callsStore = useCallsStore(); + const isJoining = ref(false); + const callDuration = ref(0); + const durationTimer = new Timer(elapsed => { + callDuration.value = elapsed; + }); + + const activeCall = computed(() => callsStore.activeCall); + const incomingCalls = computed(() => callsStore.incomingCalls); + const hasActiveCall = computed(() => callsStore.hasActiveCall); + + watch( + hasActiveCall, + active => { + if (active) { + durationTimer.start(); + } else { + durationTimer.stop(); + callDuration.value = 0; + } + }, + { immediate: true } + ); + + onMounted(() => { + TwilioVoiceClient.addEventListener('call:disconnected', () => + callsStore.clearActiveCall() + ); + }); + + onUnmounted(() => { + durationTimer.stop(); + TwilioVoiceClient.removeEventListener('call:disconnected', () => + callsStore.clearActiveCall() + ); + }); + + const endCall = async ({ conversationId, inboxId }) => { + await VoiceAPI.leaveConference(inboxId, conversationId); + TwilioVoiceClient.endClientCall(); + durationTimer.stop(); + callsStore.clearActiveCall(); + }; + + const joinCall = async ({ conversationId, inboxId, callSid }) => { + if (isJoining.value) return null; + + isJoining.value = true; + try { + const device = await TwilioVoiceClient.initializeDevice(inboxId); + if (!device) return null; + + const joinResponse = await VoiceAPI.joinConference({ + conversationId, + inboxId, + callSid, + }); + + await TwilioVoiceClient.joinClientCall({ + to: joinResponse?.conference_sid, + conversationId, + }); + + callsStore.setCallActive(callSid); + durationTimer.start(); + + return { conferenceSid: joinResponse?.conference_sid }; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to join call:', error); + return null; + } finally { + isJoining.value = false; + } + }; + + const rejectIncomingCall = callSid => { + TwilioVoiceClient.endClientCall(); + callsStore.dismissCall(callSid); + }; + + const dismissCall = callSid => { + callsStore.dismissCall(callSid); + }; + + const formattedCallDuration = computed(() => { + const minutes = Math.floor(callDuration.value / 60); + const seconds = callDuration.value % 60; + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + }); + + return { + activeCall, + incomingCalls, + hasActiveCall, + isJoining, + formattedCallDuration, + joinCall, + endCall, + rejectIncomingCall, + dismissCall, + }; +} diff --git a/app/javascript/dashboard/helper/Timer.js b/app/javascript/dashboard/helper/Timer.js new file mode 100644 index 000000000..f706de867 --- /dev/null +++ b/app/javascript/dashboard/helper/Timer.js @@ -0,0 +1,28 @@ +export default class Timer { + constructor(onTick = null) { + this.elapsed = 0; + this.intervalId = null; + this.onTick = onTick; + } + + start() { + if (this.intervalId) { + clearInterval(this.intervalId); + } + this.elapsed = 0; + this.intervalId = setInterval(() => { + this.elapsed += 1; + if (this.onTick) { + this.onTick(this.elapsed); + } + }, 1000); + } + + stop() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + this.elapsed = 0; + } +} diff --git a/app/javascript/dashboard/helper/specs/Timer.spec.js b/app/javascript/dashboard/helper/specs/Timer.spec.js new file mode 100644 index 000000000..8886726cc --- /dev/null +++ b/app/javascript/dashboard/helper/specs/Timer.spec.js @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Timer from '../Timer'; + +describe('Timer', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + describe('constructor', () => { + it('initializes with elapsed 0 and no interval', () => { + const timer = new Timer(); + expect(timer.elapsed).toBe(0); + expect(timer.intervalId).toBeNull(); + }); + + it('accepts an onTick callback', () => { + const onTick = vi.fn(); + const timer = new Timer(onTick); + expect(timer.onTick).toBe(onTick); + }); + }); + + describe('start', () => { + it('starts the timer and increments elapsed every second', () => { + const timer = new Timer(); + timer.start(); + + expect(timer.elapsed).toBe(0); + + vi.advanceTimersByTime(1000); + expect(timer.elapsed).toBe(1); + + vi.advanceTimersByTime(1000); + expect(timer.elapsed).toBe(2); + + vi.advanceTimersByTime(3000); + expect(timer.elapsed).toBe(5); + }); + + it('calls onTick callback with elapsed value', () => { + const onTick = vi.fn(); + const timer = new Timer(onTick); + timer.start(); + + vi.advanceTimersByTime(1000); + expect(onTick).toHaveBeenCalledWith(1); + + vi.advanceTimersByTime(1000); + expect(onTick).toHaveBeenCalledWith(2); + + expect(onTick).toHaveBeenCalledTimes(2); + }); + + it('resets elapsed to 0 when restarted', () => { + const timer = new Timer(); + timer.start(); + + vi.advanceTimersByTime(5000); + expect(timer.elapsed).toBe(5); + + timer.start(); + expect(timer.elapsed).toBe(0); + + vi.advanceTimersByTime(2000); + expect(timer.elapsed).toBe(2); + }); + + it('clears previous interval when restarted', () => { + const timer = new Timer(); + timer.start(); + const firstIntervalId = timer.intervalId; + + timer.start(); + expect(timer.intervalId).not.toBe(firstIntervalId); + }); + }); + + describe('stop', () => { + it('stops the timer and resets elapsed to 0', () => { + const timer = new Timer(); + timer.start(); + + vi.advanceTimersByTime(3000); + expect(timer.elapsed).toBe(3); + + timer.stop(); + expect(timer.elapsed).toBe(0); + expect(timer.intervalId).toBeNull(); + }); + + it('prevents further increments after stopping', () => { + const timer = new Timer(); + timer.start(); + + vi.advanceTimersByTime(2000); + timer.stop(); + + vi.advanceTimersByTime(5000); + expect(timer.elapsed).toBe(0); + }); + + it('handles stop when timer is not running', () => { + const timer = new Timer(); + expect(() => timer.stop()).not.toThrow(); + expect(timer.elapsed).toBe(0); + }); + }); +}); diff --git a/app/javascript/dashboard/helper/voice.js b/app/javascript/dashboard/helper/voice.js new file mode 100644 index 000000000..9f753a811 --- /dev/null +++ b/app/javascript/dashboard/helper/voice.js @@ -0,0 +1,79 @@ +import { CONTENT_TYPES } from 'dashboard/components-next/message/constants'; +import { useCallsStore } from 'dashboard/stores/calls'; +import types from 'dashboard/store/mutation-types'; + +export const TERMINAL_STATUSES = [ + 'completed', + 'busy', + 'failed', + 'no-answer', + 'canceled', + 'missed', + 'ended', +]; + +export const isInbound = direction => direction === 'inbound'; + +const isVoiceCallMessage = message => { + return CONTENT_TYPES.VOICE_CALL === message?.content_type; +}; + +const shouldSkipCall = (callDirection, senderId, currentUserId) => { + return callDirection === 'outbound' && senderId !== currentUserId; +}; + +function extractCallData(message) { + const contentData = message?.content_attributes?.data || {}; + return { + callSid: contentData.call_sid, + status: contentData.status, + callDirection: contentData.call_direction, + conversationId: message?.conversation_id, + senderId: message?.sender?.id, + }; +} + +export function handleVoiceCallCreated(message, currentUserId) { + if (!isVoiceCallMessage(message)) return; + + const { callSid, callDirection, conversationId, senderId } = + extractCallData(message); + + if (shouldSkipCall(callDirection, senderId, currentUserId)) return; + + const callsStore = useCallsStore(); + callsStore.addCall({ + callSid, + conversationId, + callDirection, + senderId, + }); +} + +export function handleVoiceCallUpdated(commit, message, currentUserId) { + if (!isVoiceCallMessage(message)) return; + + const { callSid, status, callDirection, conversationId, senderId } = + extractCallData(message); + + const callsStore = useCallsStore(); + + callsStore.handleCallStatusChanged({ callSid, status, conversationId }); + + const callInfo = { conversationId, callStatus: status }; + commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo); + commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo); + + const isNewCall = + status === 'ringing' && + !shouldSkipCall(callDirection, senderId, currentUserId); + + if (isNewCall) { + callsStore.addCall({ + callSid, + conversationId, + callDirection, + senderId, + }); + } +} diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index d2f31e000..ec355e412 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -275,6 +275,16 @@ "SIDEBAR": { "CONTACT": "Contact", "COPILOT": "Copilot" + }, + "VOICE_WIDGET": { + "INCOMING_CALL": "Incoming call", + "OUTGOING_CALL": "Outgoing call", + "CALL_IN_PROGRESS": "Call in progress", + "NOT_ANSWERED_YET": "Not answered yet", + "HANDLED_IN_ANOTHER_TAB": "Being handled in another tab", + "REJECT_CALL": "Reject", + "JOIN_CALL": "Join call", + "END_CALL": "End call" } }, "EMAIL_TRANSCRIPT": { diff --git a/app/javascript/dashboard/routes/dashboard/Dashboard.vue b/app/javascript/dashboard/routes/dashboard/Dashboard.vue index 0a9f841fa..8b79b1372 100644 --- a/app/javascript/dashboard/routes/dashboard/Dashboard.vue +++ b/app/javascript/dashboard/routes/dashboard/Dashboard.vue @@ -1,5 +1,5 @@