Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59663dd558 | ||
|
|
ff68c3a74f | ||
|
|
58cec84b93 | ||
|
|
8311657f9c | ||
|
|
62376701da | ||
|
|
d2e6d6aee3 | ||
|
|
53c21e6ad3 | ||
|
|
d408f664cb | ||
|
|
20d97be4c9 | ||
|
|
73140998ff | ||
|
|
6365a7ea53 | ||
|
|
2adc040a8f | ||
|
|
86da3f7c06 | ||
|
|
c22a31c198 | ||
|
|
4e9f644646 | ||
|
|
bfa8a8ed60 | ||
|
|
7ab60d9f9c | ||
|
|
faac1486e9 | ||
|
|
8340bd615c | ||
|
|
75d3569f22 | ||
|
|
545453537f | ||
|
|
d75702c6b2 | ||
|
|
b76ec878f1 | ||
|
|
a954e1eaca | ||
|
|
bc5f1722e1 | ||
|
|
8f39e62570 | ||
|
|
7520ca7a99 | ||
|
|
35f4e63605 | ||
|
|
5773089865 | ||
|
|
d01c7d3fa7 | ||
|
|
959d2c0d8c | ||
|
|
622f29a0b7 |
+16
-13
@@ -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)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.8.0
|
||||
4.9.2
|
||||
|
||||
@@ -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: [] }]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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) {
|
||||
|
||||
+7
-4
@@ -171,10 +171,13 @@ const onPaste = e => {
|
||||
const files = e.clipboardData?.files;
|
||||
if (!files?.length) return;
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
onFileUpload({ file, name, type, size });
|
||||
});
|
||||
// Filter valid files (non-zero size)
|
||||
Array.from(files)
|
||||
.filter(file => file.size > 0)
|
||||
.forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
onFileUpload({ file, name, type, size });
|
||||
});
|
||||
};
|
||||
|
||||
useEventListener(document, 'paste', onPaste);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
isJoining,
|
||||
joinCall,
|
||||
endCall: endCallSession,
|
||||
rejectIncomingCall,
|
||||
dismissCall,
|
||||
formattedCallDuration,
|
||||
} = useCallSession();
|
||||
|
||||
const getCallInfo = call => {
|
||||
const conversation = store.getters.getConversationById(call?.conversationId);
|
||||
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
|
||||
const sender = conversation?.meta?.sender;
|
||||
return {
|
||||
conversation,
|
||||
inbox,
|
||||
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
|
||||
inboxName: inbox?.name || 'Customer support',
|
||||
avatar: sender?.avatar || sender?.thumbnail,
|
||||
};
|
||||
};
|
||||
|
||||
const handleEndCall = async () => {
|
||||
const call = activeCall.value;
|
||||
if (!call) return;
|
||||
|
||||
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
|
||||
if (!inboxId) return;
|
||||
|
||||
await endCallSession({
|
||||
conversationId: call.conversationId,
|
||||
inboxId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleJoinCall = async call => {
|
||||
const { conversation } = getCallInfo(call);
|
||||
if (!call || !conversation || isJoining.value) return;
|
||||
|
||||
// End current active call before joining new one
|
||||
if (hasActiveCall.value) {
|
||||
await handleEndCall();
|
||||
}
|
||||
|
||||
const result = await joinCall({
|
||||
conversationId: call.conversationId,
|
||||
inboxId: conversation.inbox_id,
|
||||
callSid: call.callSid,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: call.conversationId },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-join outbound calls when window is visible
|
||||
watch(
|
||||
() => incomingCalls.value[0],
|
||||
call => {
|
||||
if (
|
||||
call?.callDirection === 'outbound' &&
|
||||
!hasActiveCall.value &&
|
||||
WindowVisibilityHelper.isWindowVisible()
|
||||
) {
|
||||
handleJoinCall(call);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="incomingCalls.length || hasActiveCall"
|
||||
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
|
||||
>
|
||||
<!-- Incoming Calls (shown above active call) -->
|
||||
<div
|
||||
v-for="call in hasActiveCall ? incomingCalls : []"
|
||||
:key="call.callSid"
|
||||
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
|
||||
<Avatar
|
||||
:src="getCallInfo(call).avatar"
|
||||
:name="getCallInfo(call).contactName"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{ getCallInfo(call).contactName }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11 truncate">
|
||||
{{ getCallInfo(call).inboxName }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
@click="dismissCall(call.callSid)"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||
</button>
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleJoinCall(call)"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Call Widget -->
|
||||
<div
|
||||
v-if="hasActiveCall || incomingCalls.length"
|
||||
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div
|
||||
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||
:class="{ 'animate-pulse': !hasActiveCall }"
|
||||
>
|
||||
<Avatar
|
||||
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
|
||||
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
|
||||
</p>
|
||||
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
|
||||
{{ formattedCallDuration }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-n-slate-11">
|
||||
{{
|
||||
incomingCalls[0]?.callDirection === 'outbound'
|
||||
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
@click="
|
||||
hasActiveCall
|
||||
? handleEndCall()
|
||||
: rejectIncomingCall(incomingCalls[0]?.callSid)
|
||||
"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!hasActiveCall"
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleJoinCall(incomingCalls[0])"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -676,11 +676,18 @@ function createEditorView() {
|
||||
typingIndicator.stop();
|
||||
emit('blur');
|
||||
},
|
||||
paste: (_view, event) => {
|
||||
paste: (view, event) => {
|
||||
if (props.disabled) return;
|
||||
const data = event.clipboardData.files;
|
||||
if (data.length > 0) {
|
||||
event.preventDefault();
|
||||
const { files } = event.clipboardData;
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
// Paste text content alongside files (e.g., spreadsheet data from Numbers app)
|
||||
// Numbers app includes invalid 0-byte attachments with text, so we paste the text here
|
||||
// while ReplyBox filters and handles valid file attachments
|
||||
const text = event.clipboardData.getData('text/plain');
|
||||
if (text) {
|
||||
view.dispatch(view.state.tr.insertText(text));
|
||||
emitOnChange();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -692,20 +692,20 @@ export default {
|
||||
},
|
||||
onPaste(e) {
|
||||
// Don't handle paste if compose new conversation modal is open
|
||||
if (this.newConversationModalActive) {
|
||||
return;
|
||||
}
|
||||
if (this.newConversationModalActive) return;
|
||||
|
||||
const data = e.clipboardData.files;
|
||||
if (!this.showRichContentEditor && data.length !== 0) {
|
||||
this.$refs.messageInput?.$el?.blur();
|
||||
}
|
||||
if (!data.length || !data[0]) {
|
||||
return;
|
||||
}
|
||||
data.forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
this.onFileUpload({ name, type, size, file: file });
|
||||
});
|
||||
|
||||
// Filter valid files (non-zero size)
|
||||
Array.from(e.clipboardData.files)
|
||||
.filter(file => file.size > 0)
|
||||
.forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
this.onFileUpload({ name, type, size, file });
|
||||
});
|
||||
},
|
||||
toggleUserMention(currentMentionState) {
|
||||
this.showUserMentions = currentMentionState;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Quote detection strategies
|
||||
const QUOTE_INDICATORS = [
|
||||
'.gmail_quote_container',
|
||||
@@ -29,7 +31,7 @@ export class EmailQuoteExtractor {
|
||||
static extractQuotes(htmlContent) {
|
||||
// Create a temporary DOM element to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
|
||||
// Remove elements matching class selectors
|
||||
QUOTE_INDICATORS.forEach(selector => {
|
||||
@@ -56,7 +58,7 @@ export class EmailQuoteExtractor {
|
||||
*/
|
||||
static hasQuotes(htmlContent) {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
|
||||
// Check for class-based quotes
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { format, parseISO, isValid as isValidDate } from 'date-fns';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* Extracts plain text from HTML content
|
||||
@@ -13,7 +14,7 @@ export const extractPlainTextFromHtml = html => {
|
||||
return html.replace(/<[^>]*>/g, ' ');
|
||||
}
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(html);
|
||||
return tempDiv.textContent || tempDiv.innerText || '';
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -96,4 +96,58 @@ describe('EmailQuoteExtractor', () => {
|
||||
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
|
||||
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
|
||||
});
|
||||
|
||||
describe('HTML sanitization', () => {
|
||||
it('removes onerror handlers from img tags in extractQuotes', () => {
|
||||
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
expect(cleanedHtml).not.toContain('onerror');
|
||||
expect(cleanedHtml).toContain('<p>Hello</p>');
|
||||
});
|
||||
|
||||
it('removes onerror handlers from img tags in hasQuotes', () => {
|
||||
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
|
||||
// Should not throw and should safely check for quotes
|
||||
const result = EmailQuoteExtractor.hasQuotes(maliciousHtml);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('removes script tags in extractQuotes', () => {
|
||||
const maliciousHtml =
|
||||
'<p>Content</p><script>alert("xss")</script><p>More</p>';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
expect(cleanedHtml).not.toContain('<script');
|
||||
expect(cleanedHtml).not.toContain('alert');
|
||||
expect(cleanedHtml).toContain('<p>Content</p>');
|
||||
expect(cleanedHtml).toContain('<p>More</p>');
|
||||
});
|
||||
|
||||
it('removes onclick handlers in extractQuotes', () => {
|
||||
const maliciousHtml = '<p onclick="alert(1)">Click me</p>';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
expect(cleanedHtml).not.toContain('onclick');
|
||||
expect(cleanedHtml).toContain('Click me');
|
||||
});
|
||||
|
||||
it('removes javascript: URLs in extractQuotes', () => {
|
||||
const maliciousHtml = '<a href="javascript:alert(1)">Link</a>';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
expect(cleanedHtml).not.toContain('javascript:');
|
||||
expect(cleanedHtml).toContain('Link');
|
||||
});
|
||||
|
||||
it('removes encoded payloads with event handlers in extractQuotes', () => {
|
||||
const maliciousHtml =
|
||||
'<img src="x" id="PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" onerror="eval(atob(this.id))">';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
expect(cleanedHtml).not.toContain('onerror');
|
||||
expect(cleanedHtml).not.toContain('eval');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,26 @@ describe('quotedEmailHelper', () => {
|
||||
expect(result).toContain('Line 1');
|
||||
expect(result).toContain('Line 2');
|
||||
});
|
||||
|
||||
it('sanitizes onerror handlers from img tags', () => {
|
||||
const html = '<p>Hello</p><img src="x" onerror="alert(1)">';
|
||||
const result = extractPlainTextFromHtml(html);
|
||||
expect(result).toBe('Hello');
|
||||
});
|
||||
|
||||
it('sanitizes script tags', () => {
|
||||
const html = '<p>Safe</p><script>alert(1)</script><p>Content</p>';
|
||||
const result = extractPlainTextFromHtml(html);
|
||||
expect(result).toContain('Safe');
|
||||
expect(result).toContain('Content');
|
||||
expect(result).not.toContain('alert');
|
||||
});
|
||||
|
||||
it('sanitizes onclick handlers', () => {
|
||||
const html = '<p onclick="alert(1)">Click me</p>';
|
||||
const result = extractPlainTextFromHtml(html);
|
||||
expect(result).toBe('Click me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailSenderName', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { defineAsyncComponent, ref } from 'vue';
|
||||
import { defineAsyncComponent, ref, computed } from 'vue';
|
||||
|
||||
import NextSidebar from 'next/sidebar/Sidebar.vue';
|
||||
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
|
||||
@@ -16,10 +16,15 @@ const CommandBar = defineAsyncComponent(
|
||||
() => import('./commands/commandbar.vue')
|
||||
);
|
||||
|
||||
const FloatingCallWidget = defineAsyncComponent(
|
||||
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
|
||||
);
|
||||
|
||||
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
|
||||
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
|
||||
|
||||
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -30,6 +35,7 @@ export default {
|
||||
UpgradePage,
|
||||
CopilotLauncher,
|
||||
CopilotContainer,
|
||||
FloatingCallWidget,
|
||||
MobileSidebarLauncher,
|
||||
},
|
||||
setup() {
|
||||
@@ -37,6 +43,7 @@ export default {
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const { accountId } = useAccount();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
@@ -44,6 +51,8 @@ export default {
|
||||
accountId,
|
||||
upgradePageRef,
|
||||
windowWidth,
|
||||
hasActiveCall: computed(() => callsStore.hasActiveCall),
|
||||
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -151,6 +160,7 @@ export default {
|
||||
@toggle="toggleMobileSidebar"
|
||||
/>
|
||||
<CopilotContainer />
|
||||
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
|
||||
</template>
|
||||
<AddAccountModal
|
||||
:show="showCreateAccountModal"
|
||||
|
||||
@@ -150,8 +150,6 @@ const derivedAttributes = computed(() =>
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
|
||||
:no-records-found="!attributes.length"
|
||||
:no-records-message="$t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404')"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
@@ -177,7 +175,7 @@ const derivedAttributes = computed(() =>
|
||||
class="max-w-xl"
|
||||
@tab-changed="onClickTabChange"
|
||||
/>
|
||||
<div class="grid gap-3">
|
||||
<div v-if="derivedAttributes.length" class="grid gap-3">
|
||||
<AttributeListItem
|
||||
v-for="attribute in derivedAttributes"
|
||||
:key="attribute.id"
|
||||
@@ -187,6 +185,12 @@ const derivedAttributes = computed(() =>
|
||||
@delete="handleDeleteAttribute"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="flex-1 py-20 text-n-slate-12 flex items-center justify-center text-base"
|
||||
>
|
||||
{{ $t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<AddAttribute
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
import messageReadActions from './actions/messageReadActions';
|
||||
import messageTranslateActions from './actions/messageTranslateActions';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import {
|
||||
handleVoiceCallCreated,
|
||||
handleVoiceCallUpdated,
|
||||
} from 'dashboard/helper/voice';
|
||||
|
||||
export const hasMessageFailedWithExternalError = pendingMessage => {
|
||||
// This helper is used to check if the message has failed with an external error.
|
||||
@@ -299,7 +303,7 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
addMessage({ commit }, message) {
|
||||
addMessage({ commit, rootGetters }, message) {
|
||||
commit(types.ADD_MESSAGE, message);
|
||||
if (message.message_type === MESSAGE_TYPE.INCOMING) {
|
||||
commit(types.SET_CONVERSATION_CAN_REPLY, {
|
||||
@@ -308,10 +312,12 @@ const actions = {
|
||||
});
|
||||
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
|
||||
}
|
||||
handleVoiceCallCreated(message, rootGetters?.getCurrentUserID);
|
||||
},
|
||||
|
||||
updateMessage({ commit }, message) {
|
||||
updateMessage({ commit, rootGetters }, message) {
|
||||
commit(types.ADD_MESSAGE, message);
|
||||
handleVoiceCallUpdated(commit, message, rootGetters?.getCurrentUserID);
|
||||
},
|
||||
|
||||
deleteMessage: async function deleteLabels(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MESSAGE_STATUS } from 'shared/constants/messages';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants.js';
|
||||
|
||||
const state = {
|
||||
allConversations: [],
|
||||
@@ -24,6 +25,10 @@ const state = {
|
||||
copilotAssistant: {},
|
||||
};
|
||||
|
||||
const getConversationById = _state => conversationId => {
|
||||
return _state.allConversations.find(c => c.id === conversationId);
|
||||
};
|
||||
|
||||
// mutations
|
||||
export const mutations = {
|
||||
[types.SET_ALL_CONVERSATION](_state, conversationList) {
|
||||
@@ -270,6 +275,36 @@ export const mutations = {
|
||||
}
|
||||
},
|
||||
|
||||
[types.UPDATE_CONVERSATION_CALL_STATUS](
|
||||
_state,
|
||||
{ conversationId, callStatus }
|
||||
) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (!chat) return;
|
||||
|
||||
chat.additional_attributes = {
|
||||
...chat.additional_attributes,
|
||||
call_status: callStatus,
|
||||
};
|
||||
},
|
||||
|
||||
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (!chat) return;
|
||||
|
||||
const lastCall = (chat.messages || []).findLast(
|
||||
m => m.content_type === CONTENT_TYPES.VOICE_CALL
|
||||
);
|
||||
|
||||
if (!lastCall) return;
|
||||
|
||||
lastCall.content_attributes ??= {};
|
||||
lastCall.content_attributes.data = {
|
||||
...lastCall.content_attributes.data,
|
||||
status: callStatus,
|
||||
};
|
||||
},
|
||||
|
||||
[types.SET_ACTIVE_INBOX](_state, inboxId) {
|
||||
_state.currentInbox = inboxId ? parseInt(inboxId, 10) : null;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { mutations } from '../index';
|
||||
import types from '../../../mutation-types';
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
|
||||
it('does nothing if conversation is not found', () => {
|
||||
const state = { allConversations: [] };
|
||||
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'ringing',
|
||||
});
|
||||
expect(state.allConversations).toEqual([]);
|
||||
});
|
||||
|
||||
it('updates call_status preserving existing additional_attributes', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{ id: 1, additional_attributes: { other_attr: 'value' } },
|
||||
],
|
||||
};
|
||||
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'in-progress',
|
||||
});
|
||||
expect(state.allConversations[0].additional_attributes).toEqual({
|
||||
other_attr: 'value',
|
||||
call_status: 'in-progress',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates additional_attributes if it does not exist', () => {
|
||||
const state = { allConversations: [{ id: 1 }] };
|
||||
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'completed',
|
||||
});
|
||||
expect(state.allConversations[0].additional_attributes).toEqual({
|
||||
call_status: 'completed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
|
||||
it('does nothing if conversation is not found', () => {
|
||||
const state = { allConversations: [] };
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'ringing',
|
||||
});
|
||||
expect(state.allConversations).toEqual([]);
|
||||
});
|
||||
|
||||
it('does nothing if no voice call message exists', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
|
||||
],
|
||||
};
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'ringing',
|
||||
});
|
||||
expect(state.allConversations[0].messages[0]).toEqual({
|
||||
id: 1,
|
||||
content_type: 'text',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the last voice call message status', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { data: { status: 'ringing' } },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { data: { status: 'ringing' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'in-progress',
|
||||
});
|
||||
expect(
|
||||
state.allConversations[0].messages[0].content_attributes.data.status
|
||||
).toBe('ringing');
|
||||
expect(
|
||||
state.allConversations[0].messages[1].content_attributes.data.status
|
||||
).toBe('in-progress');
|
||||
});
|
||||
|
||||
it('creates content_attributes.data if it does not exist', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [{ id: 1, content_type: 'voice_call' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'completed',
|
||||
});
|
||||
expect(
|
||||
state.allConversations[0].messages[0].content_attributes.data.status
|
||||
).toBe('completed');
|
||||
});
|
||||
|
||||
it('preserves existing data in content_attributes.data', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: {
|
||||
data: { call_sid: 'CA123', status: 'ringing' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'in-progress',
|
||||
});
|
||||
expect(
|
||||
state.allConversations[0].messages[0].content_attributes.data
|
||||
).toEqual({
|
||||
call_sid: 'CA123',
|
||||
status: 'in-progress',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty messages array', () => {
|
||||
const state = {
|
||||
allConversations: [{ id: 1, messages: [] }],
|
||||
};
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'ringing',
|
||||
});
|
||||
expect(state.allConversations[0].messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,8 @@ export default {
|
||||
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
|
||||
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
|
||||
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
||||
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
|
||||
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
|
||||
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
|
||||
|
||||
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
|
||||
|
||||
export const useCallsStore = defineStore('calls', {
|
||||
state: () => ({
|
||||
calls: [],
|
||||
}),
|
||||
|
||||
getters: {
|
||||
activeCall: state => state.calls.find(call => call.isActive) || null,
|
||||
hasActiveCall: state => state.calls.some(call => call.isActive),
|
||||
incomingCalls: state => state.calls.filter(call => !call.isActive),
|
||||
hasIncomingCall: state => state.calls.some(call => !call.isActive),
|
||||
},
|
||||
|
||||
actions: {
|
||||
handleCallStatusChanged({ callSid, status }) {
|
||||
if (TERMINAL_STATUSES.includes(status)) {
|
||||
this.removeCall(callSid);
|
||||
}
|
||||
},
|
||||
|
||||
addCall(callData) {
|
||||
if (!callData?.callSid) return;
|
||||
const exists = this.calls.some(call => call.callSid === callData.callSid);
|
||||
if (exists) return;
|
||||
|
||||
this.calls.push({
|
||||
...callData,
|
||||
isActive: false,
|
||||
});
|
||||
},
|
||||
|
||||
removeCall(callSid) {
|
||||
const callToRemove = this.calls.find(c => c.callSid === callSid);
|
||||
if (callToRemove?.isActive) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
this.calls = this.calls.filter(c => c.callSid !== callSid);
|
||||
},
|
||||
|
||||
setCallActive(callSid) {
|
||||
this.calls = this.calls.map(call => ({
|
||||
...call,
|
||||
isActive: call.callSid === callSid,
|
||||
}));
|
||||
},
|
||||
|
||||
clearActiveCall() {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
this.calls = this.calls.filter(call => !call.isActive);
|
||||
},
|
||||
|
||||
dismissCall(callSid) {
|
||||
this.calls = this.calls.filter(call => call.callSid !== callSid);
|
||||
},
|
||||
},
|
||||
});
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.8.0'
|
||||
version: '4.9.2'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@
|
||||
- name: channel_voice
|
||||
display_name: Voice Channel
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
premium: true
|
||||
- name: notion_integration
|
||||
display_name: Notion Integration
|
||||
enabled: false
|
||||
|
||||
@@ -62,6 +62,8 @@ en:
|
||||
failed: Signup failed
|
||||
assignment_policy:
|
||||
not_found: Assignment policy not found
|
||||
attachments:
|
||||
invalid: Invalid attachment
|
||||
saml:
|
||||
feature_not_enabled: SAML feature not enabled for this account
|
||||
sso_not_enabled: SAML SSO is not enabled for this installation
|
||||
|
||||
@@ -18,6 +18,7 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
captain_integration
|
||||
advanced_search_indexing
|
||||
advanced_search
|
||||
linear_integration
|
||||
].freeze
|
||||
|
||||
# Additional features available starting with the Business plan
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.8.0",
|
||||
"version": "4.9.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
@@ -49,6 +49,7 @@
|
||||
"@sindresorhus/slugify": "2.2.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tanstack/vue-table": "^8.20.5",
|
||||
"@twilio/voice-sdk": "^2.12.4",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vue/compiler-sfc": "^3.5.8",
|
||||
"@vuelidate/core": "^2.0.3",
|
||||
|
||||
Generated
+37
@@ -67,6 +67,9 @@ importers:
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.20.5
|
||||
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
|
||||
'@twilio/voice-sdk':
|
||||
specifier: ^2.12.4
|
||||
version: 2.17.0
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.1.4
|
||||
version: 5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||
@@ -1283,9 +1286,19 @@ packages:
|
||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@twilio/voice-errors@1.7.0':
|
||||
resolution: {integrity: sha512-9TvniWpzU0iy6SYFAcDP+HG+/mNz2yAHSs7+m0DZk86lE+LoTB6J/ZONTPuxXrXWi4tso/DulSHuA0w7nIQtGg==}
|
||||
|
||||
'@twilio/voice-sdk@2.17.0':
|
||||
resolution: {integrity: sha512-dAqAfQ59xexKdVi6U1TJAKlf6aDySAinjMvXrNdAFJDzSWJ5SNh49ITxdaKR2vaUdxTc7ncFgGVeI72W2dWHjg==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/events@3.0.3':
|
||||
resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==}
|
||||
|
||||
'@types/flexsearch@0.7.6':
|
||||
resolution: {integrity: sha512-H5IXcRn96/gaDmo+rDl2aJuIJsob8dgOXDqf8K0t8rWZd1AFNaaspmRsElESiU+EWE33qfbFPgI0OC/B1g9FCA==}
|
||||
|
||||
@@ -2399,6 +2412,10 @@ packages:
|
||||
eventemitter3@5.0.1:
|
||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
expect-type@1.1.0:
|
||||
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -3119,6 +3136,10 @@ packages:
|
||||
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
loglevel@1.9.2:
|
||||
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
loupe@3.1.3:
|
||||
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
|
||||
|
||||
@@ -5750,8 +5771,20 @@ snapshots:
|
||||
|
||||
'@tootallnate/once@2.0.0': {}
|
||||
|
||||
'@twilio/voice-errors@1.7.0': {}
|
||||
|
||||
'@twilio/voice-sdk@2.17.0':
|
||||
dependencies:
|
||||
'@twilio/voice-errors': 1.7.0
|
||||
'@types/events': 3.0.3
|
||||
events: 3.3.0
|
||||
loglevel: 1.9.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/events@3.0.3': {}
|
||||
|
||||
'@types/flexsearch@0.7.6': {}
|
||||
|
||||
'@types/fs-extra@9.0.13':
|
||||
@@ -7137,6 +7170,8 @@ snapshots:
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
expect-type@1.1.0: {}
|
||||
|
||||
extend-shallow@2.0.1:
|
||||
@@ -7966,6 +8001,8 @@ snapshots:
|
||||
strip-ansi: 7.1.0
|
||||
wrap-ansi: 9.0.0
|
||||
|
||||
loglevel@1.9.2: {}
|
||||
|
||||
loupe@3.1.3: {}
|
||||
|
||||
lower-case@2.0.2:
|
||||
|
||||
@@ -141,21 +141,14 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
||||
end
|
||||
|
||||
it 'Saves file in the automation actions to send an attachments' do
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||
filename: 'avatar.png',
|
||||
content_type: 'image/png'
|
||||
)
|
||||
|
||||
expect(account.automation_rules.count).to eq(0)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/upload/",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: { attachment: file }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
blob = response.parsed_body
|
||||
|
||||
expect(blob['blob_key']).to be_present
|
||||
expect(blob['blob_id']).to be_present
|
||||
|
||||
params[:actions] = [
|
||||
{
|
||||
'action_name': :send_message,
|
||||
@@ -163,7 +156,7 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
||||
},
|
||||
{
|
||||
'action_name': :send_attachment,
|
||||
'action_params': [blob['blob_id']]
|
||||
'action_params': [blob.signed_id]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -177,29 +170,25 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
||||
end
|
||||
|
||||
it 'Saves files in the automation actions to send multiple attachments' do
|
||||
file_1 = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
||||
file_2 = fixture_file_upload(Rails.root.join('spec/assets/sample.png'), 'image/png')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/upload/",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: { attachment: file_1 }
|
||||
|
||||
blob_1 = response.parsed_body
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/upload/",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: { attachment: file_2 }
|
||||
|
||||
blob_2 = response.parsed_body
|
||||
blob_1 = ActiveStorage::Blob.create_and_upload!(
|
||||
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||
filename: 'avatar.png',
|
||||
content_type: 'image/png'
|
||||
)
|
||||
blob_2 = ActiveStorage::Blob.create_and_upload!(
|
||||
io: Rails.root.join('spec/assets/sample.png').open,
|
||||
filename: 'sample.png',
|
||||
content_type: 'image/png'
|
||||
)
|
||||
|
||||
params[:actions] = [
|
||||
{
|
||||
'action_name': :send_attachment,
|
||||
'action_params': [blob_1['blob_id']]
|
||||
'action_params': [blob_1.signed_id]
|
||||
},
|
||||
{
|
||||
'action_name': :send_attachment,
|
||||
'action_params': [blob_2['blob_id']]
|
||||
'action_params': [blob_2.signed_id]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -210,6 +199,46 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
||||
automation_rule = account.automation_rules.first
|
||||
expect(automation_rule.files.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns error for invalid attachment blob_id' do
|
||||
params[:actions] = [
|
||||
{
|
||||
'action_name': :send_attachment,
|
||||
'action_params': ['invalid_blob_id']
|
||||
}
|
||||
]
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/automation_rules",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: params
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
|
||||
end
|
||||
|
||||
it 'stores the original blob_id in action_params after create' do
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||
filename: 'avatar.png',
|
||||
content_type: 'image/png'
|
||||
)
|
||||
|
||||
params[:actions] = [
|
||||
{
|
||||
'action_name': :send_attachment,
|
||||
'action_params': [blob.signed_id]
|
||||
}
|
||||
]
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/automation_rules",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: params
|
||||
|
||||
automation_rule = account.automation_rules.first
|
||||
attachment_action = automation_rule.actions.find { |a| a['action_name'] == 'send_attachment' }
|
||||
expect(attachment_action['action_params'].first).to be_a(Integer)
|
||||
expect(attachment_action['action_params'].first).to eq(automation_rule.files.first.blob_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -328,6 +357,68 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
||||
expect(body[:payload][:active]).to be(false)
|
||||
expect(automation_rule.reload.active).to be(false)
|
||||
end
|
||||
|
||||
it 'allows update with existing blob_id' do
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||
filename: 'avatar.png',
|
||||
content_type: 'image/png'
|
||||
)
|
||||
|
||||
automation_rule.update!(actions: [{ 'action_name' => 'send_attachment', 'action_params' => [blob.id] }])
|
||||
automation_rule.files.attach(blob)
|
||||
|
||||
update_params[:actions] = [
|
||||
{
|
||||
'action_name': :send_attachment,
|
||||
'action_params': [blob.id]
|
||||
}
|
||||
]
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: update_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'returns error for invalid blob_id on update' do
|
||||
update_params[:actions] = [
|
||||
{
|
||||
'action_name': :send_attachment,
|
||||
'action_params': [999_999]
|
||||
}
|
||||
]
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: update_params
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
|
||||
end
|
||||
|
||||
it 'allows adding new attachment on update with signed blob_id' do
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||
filename: 'avatar.png',
|
||||
content_type: 'image/png'
|
||||
)
|
||||
|
||||
update_params[:actions] = [
|
||||
{
|
||||
'action_name': :send_attachment,
|
||||
'action_params': [blob.signed_id]
|
||||
}
|
||||
]
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: update_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(automation_rule.reload.files.count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -127,18 +127,11 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
|
||||
end
|
||||
|
||||
it 'Saves file in the macros actions to send an attachments' do
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/upload/",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: { attachment: file }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
blob = response.parsed_body
|
||||
|
||||
expect(blob['blob_key']).to be_present
|
||||
expect(blob['blob_id']).to be_present
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||
filename: 'avatar.png',
|
||||
content_type: 'image/png'
|
||||
)
|
||||
|
||||
params[:actions] = [
|
||||
{
|
||||
@@ -147,7 +140,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
|
||||
},
|
||||
{
|
||||
'action_name': :send_attachment,
|
||||
'action_params': [blob['blob_id']]
|
||||
'action_params': [blob.signed_id]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -159,6 +152,46 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
|
||||
expect(macro.files.presence).to be_truthy
|
||||
expect(macro.files.count).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns error for invalid attachment blob_id' do
|
||||
params[:actions] = [
|
||||
{
|
||||
'action_name': :send_attachment,
|
||||
'action_params': ['invalid_blob_id']
|
||||
}
|
||||
]
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/macros",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: params
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
|
||||
end
|
||||
|
||||
it 'stores the original blob_id in action_params after create' do
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||
filename: 'avatar.png',
|
||||
content_type: 'image/png'
|
||||
)
|
||||
|
||||
params[:actions] = [
|
||||
{
|
||||
'action_name': :send_attachment,
|
||||
'action_params': [blob.signed_id]
|
||||
}
|
||||
]
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/macros",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: params
|
||||
|
||||
macro = account.macros.last
|
||||
attachment_action = macro.actions.find { |a| a['action_name'] == 'send_attachment' }
|
||||
expect(attachment_action['action_params'].first).to be_a(Integer)
|
||||
expect(attachment_action['action_params'].first).to eq(macro.files.first.blob_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -202,6 +235,47 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(json_response['error']).to eq('You are not authorized to do this action')
|
||||
end
|
||||
|
||||
it 'allows update with existing blob_id' do
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||
filename: 'avatar.png',
|
||||
content_type: 'image/png'
|
||||
)
|
||||
|
||||
macro.update!(actions: [{ 'action_name' => 'send_attachment', 'action_params' => [blob.id] }])
|
||||
macro.files.attach(blob)
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
|
||||
params: { actions: [{ 'action_name': :send_attachment, 'action_params': [blob.id] }] },
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'returns error for invalid blob_id on update' do
|
||||
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
|
||||
params: { actions: [{ 'action_name': :send_attachment, 'action_params': [999_999] }] },
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
|
||||
end
|
||||
|
||||
it 'allows adding new attachment on update with signed blob_id' do
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||
filename: 'avatar.png',
|
||||
content_type: 'image/png'
|
||||
)
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
|
||||
params: { actions: [{ 'action_name': :send_attachment, 'action_params': [blob.signed_id] }] },
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(macro.reload.files.count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
||||
blob = response.parsed_body
|
||||
expect(blob['errors']).to be_nil
|
||||
expect(blob['file_url']).to be_present
|
||||
expect(blob['blob_key']).to be_present
|
||||
expect(blob['blob_id']).to be_present
|
||||
end
|
||||
|
||||
@@ -53,7 +52,6 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
||||
blob = response.parsed_body
|
||||
expect(blob['error']).to be_nil
|
||||
expect(blob['file_url']).to be_present
|
||||
expect(blob['blob_key']).to be_present
|
||||
expect(blob['blob_id']).to be_present
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user