feat: Voice channel creation Flow (#11775)
This PR introduces a new channel type for voice conversations. ref: #11481 ## Changes - Add database migration for channel_voice table with phone_number and provider_config - Create Channel::Voice model with E.164 phone number validation and Twilio config validation - Add voice channel association to Account model - Extend inbox helpers and types to support voice channels - Add voice channel setup UI with Twilio configuration form - Include voice channel in channel factory and list components - Add API routes and store actions for voice channel creation - Add comprehensive translations for voice channel management --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
parent
97efd36bc5
commit
b7f2c151bf
@ -81,11 +81,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_channel
|
def create_channel
|
||||||
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
|
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||||
|
|
||||||
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
|
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def allowed_channel_types
|
||||||
|
%w[web_widget api email line telegram whatsapp sms]
|
||||||
|
end
|
||||||
|
|
||||||
def update_inbox_working_hours
|
def update_inbox_working_hours
|
||||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||||
end
|
end
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export function useChannelIcon(inbox) {
|
|||||||
'Channel::WebWidget': 'i-ri-global-fill',
|
'Channel::WebWidget': 'i-ri-global-fill',
|
||||||
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
|
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
|
||||||
'Channel::Instagram': 'i-ri-instagram-fill',
|
'Channel::Instagram': 'i-ri-instagram-fill',
|
||||||
|
'Channel::Voice': 'i-ri-phone-fill',
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerIconMap = {
|
const providerIconMap = {
|
||||||
|
|||||||
@ -19,6 +19,12 @@ describe('useChannelIcon', () => {
|
|||||||
expect(icon).toBe('i-ri-whatsapp-fill');
|
expect(icon).toBe('i-ri-whatsapp-fill');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns correct icon for Voice channel', () => {
|
||||||
|
const inbox = { channel_type: 'Channel::Voice' };
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-ri-phone-fill');
|
||||||
|
});
|
||||||
|
|
||||||
describe('Email channel', () => {
|
describe('Email channel', () => {
|
||||||
it('returns mail icon for generic email channel', () => {
|
it('returns mail icon for generic email channel', () => {
|
||||||
const inbox = { channel_type: 'Channel::Email' };
|
const inbox = { channel_type: 'Channel::Email' };
|
||||||
|
|||||||
@ -1,51 +1,21 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
import { computed, ref, onMounted, nextTick, getCurrentInstance } from 'vue';
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: { type: [String, Number], default: '' },
|
||||||
type: [String, Number],
|
type: { type: String, default: 'text' },
|
||||||
default: '',
|
customInputClass: { type: [String, Object, Array], default: '' },
|
||||||
},
|
placeholder: { type: String, default: '' },
|
||||||
type: {
|
label: { type: String, default: '' },
|
||||||
type: String,
|
id: { type: String, default: '' },
|
||||||
default: 'text',
|
message: { type: String, default: '' },
|
||||||
},
|
disabled: { type: Boolean, default: false },
|
||||||
customInputClass: {
|
|
||||||
type: [String, Object, Array],
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
messageType: {
|
messageType: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'info',
|
default: 'info',
|
||||||
validator: value => ['info', 'error', 'success'].includes(value),
|
validator: value => ['info', 'error', 'success'].includes(value),
|
||||||
},
|
},
|
||||||
min: {
|
min: { type: String, default: '' },
|
||||||
type: String,
|
autofocus: { type: Boolean, default: false },
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
autofocus: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@ -56,6 +26,10 @@ const emit = defineEmits([
|
|||||||
'enter',
|
'enter',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Generate a unique ID per component instance when `id` prop is not provided.
|
||||||
|
const { uid } = getCurrentInstance();
|
||||||
|
const uniqueId = computed(() => props.id || `input-${uid}`);
|
||||||
|
|
||||||
const isFocused = ref(false);
|
const isFocused = ref(false);
|
||||||
const inputRef = ref(null);
|
const inputRef = ref(null);
|
||||||
|
|
||||||
@ -111,7 +85,7 @@ onMounted(() => {
|
|||||||
<div class="relative flex flex-col min-w-0 gap-1">
|
<div class="relative flex flex-col min-w-0 gap-1">
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="id"
|
:for="uniqueId"
|
||||||
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
@ -119,7 +93,7 @@ onMounted(() => {
|
|||||||
<!-- Added prefix slot to allow adding icons to the input -->
|
<!-- Added prefix slot to allow adding icons to the input -->
|
||||||
<slot name="prefix" />
|
<slot name="prefix" />
|
||||||
<input
|
<input
|
||||||
:id="id"
|
:id="uniqueId"
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
:class="[
|
:class="[
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export default {
|
|||||||
<button
|
<button
|
||||||
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
|
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
|
<img :src="src" :alt="title" draggable="false" class="w-1/2 my-4 mx-auto" />
|
||||||
<h3
|
<h3
|
||||||
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
|
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -41,6 +41,10 @@ export default {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'voice') {
|
||||||
|
return this.enabledFeatures.channel_voice;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'website',
|
'website',
|
||||||
'twilio',
|
'twilio',
|
||||||
@ -50,6 +54,7 @@ export default {
|
|||||||
'telegram',
|
'telegram',
|
||||||
'line',
|
'line',
|
||||||
'instagram',
|
'instagram',
|
||||||
|
'voice',
|
||||||
].includes(key);
|
].includes(key);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -125,6 +125,10 @@ export const useInbox = () => {
|
|||||||
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isAVoiceChannel = computed(() => {
|
||||||
|
return channelType.value === INBOX_TYPES.VOICE;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inbox,
|
inbox,
|
||||||
isAFacebookInbox,
|
isAFacebookInbox,
|
||||||
@ -142,5 +146,6 @@ export const useInbox = () => {
|
|||||||
is360DialogWhatsAppChannel,
|
is360DialogWhatsAppChannel,
|
||||||
isAnEmailChannel,
|
isAnEmailChannel,
|
||||||
isAnInstagramChannel,
|
isAnInstagramChannel,
|
||||||
|
isAVoiceChannel,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export const INBOX_TYPES = {
|
|||||||
LINE: 'Channel::Line',
|
LINE: 'Channel::Line',
|
||||||
SMS: 'Channel::Sms',
|
SMS: 'Channel::Sms',
|
||||||
INSTAGRAM: 'Channel::Instagram',
|
INSTAGRAM: 'Channel::Instagram',
|
||||||
|
VOICE: 'Channel::Voice',
|
||||||
};
|
};
|
||||||
|
|
||||||
const INBOX_ICON_MAP_FILL = {
|
const INBOX_ICON_MAP_FILL = {
|
||||||
@ -22,6 +23,7 @@ const INBOX_ICON_MAP_FILL = {
|
|||||||
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
|
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
|
||||||
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
||||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
||||||
|
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
|
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
|
||||||
@ -36,6 +38,7 @@ const INBOX_ICON_MAP_LINE = {
|
|||||||
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
|
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
|
||||||
[INBOX_TYPES.LINE]: 'i-ri-line-line',
|
[INBOX_TYPES.LINE]: 'i-ri-line-line',
|
||||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
|
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
|
||||||
|
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
|
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
|
||||||
@ -47,6 +50,7 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
|
|||||||
|
|
||||||
case INBOX_TYPES.TWILIO:
|
case INBOX_TYPES.TWILIO:
|
||||||
case INBOX_TYPES.WHATSAPP:
|
case INBOX_TYPES.WHATSAPP:
|
||||||
|
case INBOX_TYPES.VOICE:
|
||||||
return phoneNumber || '';
|
return phoneNumber || '';
|
||||||
|
|
||||||
case INBOX_TYPES.EMAIL:
|
case INBOX_TYPES.EMAIL:
|
||||||
@ -85,6 +89,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
|||||||
case INBOX_TYPES.LINE:
|
case INBOX_TYPES.LINE:
|
||||||
return 'line';
|
return 'line';
|
||||||
|
|
||||||
|
case INBOX_TYPES.VOICE:
|
||||||
|
return 'voice';
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 'chat';
|
return 'chat';
|
||||||
}
|
}
|
||||||
@ -124,6 +131,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
|||||||
case INBOX_TYPES.INSTAGRAM:
|
case INBOX_TYPES.INSTAGRAM:
|
||||||
return 'brand-instagram';
|
return 'brand-instagram';
|
||||||
|
|
||||||
|
case INBOX_TYPES.VOICE:
|
||||||
|
return 'phone';
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 'chat';
|
return 'chat';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -268,6 +268,46 @@
|
|||||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"VOICE": {
|
||||||
|
"TITLE": "Voice Channel",
|
||||||
|
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
|
||||||
|
"PHONE_NUMBER": {
|
||||||
|
"LABEL": "Phone Number",
|
||||||
|
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
|
||||||
|
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
|
||||||
|
},
|
||||||
|
"TWILIO": {
|
||||||
|
"ACCOUNT_SID": {
|
||||||
|
"LABEL": "Account SID",
|
||||||
|
"PLACEHOLDER": "Enter your Twilio Account SID",
|
||||||
|
"REQUIRED": "Account SID is required"
|
||||||
|
},
|
||||||
|
"AUTH_TOKEN": {
|
||||||
|
"LABEL": "Auth Token",
|
||||||
|
"PLACEHOLDER": "Enter your Twilio Auth Token",
|
||||||
|
"REQUIRED": "Auth Token is required"
|
||||||
|
},
|
||||||
|
"API_KEY_SID": {
|
||||||
|
"LABEL": "API Key SID",
|
||||||
|
"PLACEHOLDER": "Enter your Twilio API Key SID",
|
||||||
|
"REQUIRED": "API Key SID is required"
|
||||||
|
},
|
||||||
|
"API_KEY_SECRET": {
|
||||||
|
"LABEL": "API Key Secret",
|
||||||
|
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||||
|
"REQUIRED": "API Key Secret is required"
|
||||||
|
},
|
||||||
|
"TWIML_APP_SID": {
|
||||||
|
"LABEL": "TwiML App SID",
|
||||||
|
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||||
|
"REQUIRED": "TwiML App SID is required"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||||
|
"API": {
|
||||||
|
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"API_CHANNEL": {
|
"API_CHANNEL": {
|
||||||
"TITLE": "API Channel",
|
"TITLE": "API Channel",
|
||||||
"DESC": "Integrate with API channel and start supporting your customers.",
|
"DESC": "Integrate with API channel and start supporting your customers.",
|
||||||
@ -789,7 +829,8 @@
|
|||||||
"TELEGRAM": "Telegram",
|
"TELEGRAM": "Telegram",
|
||||||
"LINE": "Line",
|
"LINE": "Line",
|
||||||
"API": "API Channel",
|
"API": "API Channel",
|
||||||
"INSTAGRAM": "Instagram"
|
"INSTAGRAM": "Instagram",
|
||||||
|
"VOICE": "Voice"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import Whatsapp from './channels/Whatsapp.vue';
|
|||||||
import Line from './channels/Line.vue';
|
import Line from './channels/Line.vue';
|
||||||
import Telegram from './channels/Telegram.vue';
|
import Telegram from './channels/Telegram.vue';
|
||||||
import Instagram from './channels/Instagram.vue';
|
import Instagram from './channels/Instagram.vue';
|
||||||
|
import Voice from './channels/Voice.vue';
|
||||||
|
|
||||||
const channelViewList = {
|
const channelViewList = {
|
||||||
facebook: Facebook,
|
facebook: Facebook,
|
||||||
@ -22,6 +23,7 @@ const channelViewList = {
|
|||||||
line: Line,
|
line: Line,
|
||||||
telegram: Telegram,
|
telegram: Telegram,
|
||||||
instagram: Instagram,
|
instagram: Instagram,
|
||||||
|
voice: Voice,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
|||||||
@ -36,6 +36,7 @@ export default {
|
|||||||
{ key: 'telegram', name: 'Telegram' },
|
{ key: 'telegram', name: 'Telegram' },
|
||||||
{ key: 'line', name: 'Line' },
|
{ key: 'line', name: 'Line' },
|
||||||
{ key: 'instagram', name: 'Instagram' },
|
{ key: 'instagram', name: 'Instagram' },
|
||||||
|
{ key: 'voice', name: 'Voice' },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
|
|||||||
@ -0,0 +1,184 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { required } from '@vuelidate/validators';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { isPhoneE164 } from 'shared/helpers/Validators';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
|
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
phoneNumber: '',
|
||||||
|
accountSid: '',
|
||||||
|
authToken: '',
|
||||||
|
apiKeySid: '',
|
||||||
|
apiKeySecret: '',
|
||||||
|
twimlAppSid: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('inboxes/getUIFlags');
|
||||||
|
|
||||||
|
const validationRules = {
|
||||||
|
phoneNumber: { required, isPhoneE164 },
|
||||||
|
accountSid: { required },
|
||||||
|
authToken: { required },
|
||||||
|
apiKeySid: { required },
|
||||||
|
apiKeySecret: { required },
|
||||||
|
twimlAppSid: { required },
|
||||||
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(validationRules, state);
|
||||||
|
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
||||||
|
|
||||||
|
const formErrors = computed(() => ({
|
||||||
|
phoneNumber: v$.value.phoneNumber?.$error
|
||||||
|
? t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR')
|
||||||
|
: '',
|
||||||
|
accountSid: v$.value.accountSid?.$error
|
||||||
|
? t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED')
|
||||||
|
: '',
|
||||||
|
authToken: v$.value.authToken?.$error
|
||||||
|
? t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED')
|
||||||
|
: '',
|
||||||
|
apiKeySid: v$.value.apiKeySid?.$error
|
||||||
|
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.REQUIRED')
|
||||||
|
: '',
|
||||||
|
apiKeySecret: v$.value.apiKeySecret?.$error
|
||||||
|
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
|
||||||
|
: '',
|
||||||
|
twimlAppSid: v$.value.twimlAppSid?.$error
|
||||||
|
? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED')
|
||||||
|
: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
function getProviderConfig() {
|
||||||
|
const config = {
|
||||||
|
account_sid: state.accountSid,
|
||||||
|
auth_token: state.authToken,
|
||||||
|
api_key_sid: state.apiKeySid,
|
||||||
|
api_key_secret: state.apiKeySecret,
|
||||||
|
};
|
||||||
|
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createChannel() {
|
||||||
|
const isFormValid = await v$.value.$validate();
|
||||||
|
if (!isFormValid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = await store.dispatch('inboxes/createVoiceChannel', {
|
||||||
|
name: `Voice (${state.phoneNumber})`,
|
||||||
|
voice: {
|
||||||
|
phone_number: state.phoneNumber,
|
||||||
|
provider: 'twilio',
|
||||||
|
provider_config: getProviderConfig(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
router.replace({
|
||||||
|
name: 'settings_inboxes_add_agents',
|
||||||
|
params: { page: 'new', inbox_id: channel.id },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(
|
||||||
|
error.response?.data?.message ||
|
||||||
|
t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
|
||||||
|
>
|
||||||
|
<PageHeader
|
||||||
|
:header-title="t('INBOX_MGMT.ADD.VOICE.TITLE')"
|
||||||
|
:header-content="t('INBOX_MGMT.ADD.VOICE.DESC')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="flex flex-col gap-4 flex-wrap mx-0"
|
||||||
|
@submit.prevent="createChannel"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="state.phoneNumber"
|
||||||
|
:label="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL')"
|
||||||
|
:placeholder="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
|
||||||
|
:message="formErrors.phoneNumber"
|
||||||
|
:message-type="formErrors.phoneNumber ? 'error' : 'info'"
|
||||||
|
@blur="v$.phoneNumber?.$touch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-model="state.accountSid"
|
||||||
|
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL')"
|
||||||
|
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')"
|
||||||
|
:message="formErrors.accountSid"
|
||||||
|
:message-type="formErrors.accountSid ? 'error' : 'info'"
|
||||||
|
@blur="v$.accountSid?.$touch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-model="state.authToken"
|
||||||
|
type="password"
|
||||||
|
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL')"
|
||||||
|
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')"
|
||||||
|
:message="formErrors.authToken"
|
||||||
|
:message-type="formErrors.authToken ? 'error' : 'info'"
|
||||||
|
@blur="v$.authToken?.$touch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-model="state.apiKeySid"
|
||||||
|
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
|
||||||
|
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
|
||||||
|
:message="formErrors.apiKeySid"
|
||||||
|
:message-type="formErrors.apiKeySid ? 'error' : 'info'"
|
||||||
|
@blur="v$.apiKeySid?.$touch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-model="state.apiKeySecret"
|
||||||
|
type="password"
|
||||||
|
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
|
||||||
|
:placeholder="
|
||||||
|
t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
:message="formErrors.apiKeySecret"
|
||||||
|
:message-type="formErrors.apiKeySecret ? 'error' : 'info'"
|
||||||
|
@blur="v$.apiKeySecret?.$touch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-model="state.twimlAppSid"
|
||||||
|
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
|
||||||
|
:placeholder="
|
||||||
|
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
:message="formErrors.twimlAppSid"
|
||||||
|
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
|
||||||
|
@blur="v$.twimlAppSid?.$touch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NextButton
|
||||||
|
:is-loading="uiFlags.isCreating"
|
||||||
|
:disabled="isSubmitDisabled"
|
||||||
|
:label="t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
|
||||||
|
type="submit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -29,6 +29,7 @@ const i18nMap = {
|
|||||||
'Channel::Line': 'LINE',
|
'Channel::Line': 'LINE',
|
||||||
'Channel::Api': 'API',
|
'Channel::Api': 'API',
|
||||||
'Channel::Instagram': 'INSTAGRAM',
|
'Channel::Instagram': 'INSTAGRAM',
|
||||||
|
'Channel::Voice': 'VOICE',
|
||||||
};
|
};
|
||||||
|
|
||||||
const twilioChannelName = () => {
|
const twilioChannelName = () => {
|
||||||
|
|||||||
@ -9,29 +9,7 @@ import { throwErrorMessage } from '../utils/api';
|
|||||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||||
import camelcaseKeys from 'camelcase-keys';
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
|
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||||
|
import { channelActions, buildInboxData } from './inboxes/channelActions';
|
||||||
const buildInboxData = inboxParams => {
|
|
||||||
const formData = new FormData();
|
|
||||||
const { channel = {}, ...inboxProperties } = inboxParams;
|
|
||||||
Object.keys(inboxProperties).forEach(key => {
|
|
||||||
formData.append(key, inboxProperties[key]);
|
|
||||||
});
|
|
||||||
const { selectedFeatureFlags, ...channelParams } = channel;
|
|
||||||
// selectedFeatureFlags needs to be empty when creating a website channel
|
|
||||||
if (selectedFeatureFlags) {
|
|
||||||
if (selectedFeatureFlags.length) {
|
|
||||||
selectedFeatureFlags.forEach(featureFlag => {
|
|
||||||
formData.append(`channel[selected_feature_flags][]`, featureFlag);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
formData.append('channel[selected_feature_flags][]', '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Object.keys(channelParams).forEach(key => {
|
|
||||||
formData.append(`channel[${key}]`, channel[key]);
|
|
||||||
});
|
|
||||||
return formData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
records: [],
|
records: [],
|
||||||
@ -220,6 +198,12 @@ export const actions = {
|
|||||||
throw new Error(error);
|
throw new Error(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
...channelActions,
|
||||||
|
// TODO: Extract other create channel methods to separate files to reduce file size
|
||||||
|
// - createChannel
|
||||||
|
// - createWebsiteChannel
|
||||||
|
// - createTwilioChannel
|
||||||
|
// - createFBChannel
|
||||||
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
|
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
|
||||||
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
|
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -0,0 +1,52 @@
|
|||||||
|
import * as types from '../../mutation-types';
|
||||||
|
import InboxesAPI from '../../../api/inboxes';
|
||||||
|
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
|
||||||
|
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||||
|
|
||||||
|
export const buildInboxData = inboxParams => {
|
||||||
|
const formData = new FormData();
|
||||||
|
const { channel = {}, ...inboxProperties } = inboxParams;
|
||||||
|
Object.keys(inboxProperties).forEach(key => {
|
||||||
|
formData.append(key, inboxProperties[key]);
|
||||||
|
});
|
||||||
|
const { selectedFeatureFlags, ...channelParams } = channel;
|
||||||
|
// selectedFeatureFlags needs to be empty when creating a website channel
|
||||||
|
if (selectedFeatureFlags) {
|
||||||
|
if (selectedFeatureFlags.length) {
|
||||||
|
selectedFeatureFlags.forEach(featureFlag => {
|
||||||
|
formData.append(`channel[selected_feature_flags][]`, featureFlag);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
formData.append('channel[selected_feature_flags][]', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.keys(channelParams).forEach(key => {
|
||||||
|
formData.append(`channel[${key}]`, channel[key]);
|
||||||
|
});
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendAnalyticsEvent = channelType => {
|
||||||
|
AnalyticsHelper.track(ACCOUNT_EVENTS.ADDED_AN_INBOX, {
|
||||||
|
channelType,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const channelActions = {
|
||||||
|
createVoiceChannel: async ({ commit }, params) => {
|
||||||
|
try {
|
||||||
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
|
||||||
|
const response = await InboxesAPI.create({
|
||||||
|
name: params.name,
|
||||||
|
channel: { ...params.voice, type: 'voice' },
|
||||||
|
});
|
||||||
|
commit(types.default.ADD_INBOXES, response.data);
|
||||||
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||||
|
sendAnalyticsEvent('voice');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -62,6 +62,28 @@ describe('#actions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#createVoiceChannel', () => {
|
||||||
|
it('sends correct actions if API is success', async () => {
|
||||||
|
axios.post.mockResolvedValue({ data: inboxList[0] });
|
||||||
|
await actions.createVoiceChannel({ commit }, inboxList[0]);
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
|
||||||
|
[types.default.ADD_INBOXES, inboxList[0]],
|
||||||
|
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('sends correct actions if API is error', async () => {
|
||||||
|
axios.post.mockRejectedValue({ message: 'Incorrect header' });
|
||||||
|
await expect(actions.createVoiceChannel({ commit })).rejects.toThrow(
|
||||||
|
Error
|
||||||
|
);
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
|
||||||
|
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#createFBChannel', () => {
|
describe('#createFBChannel', () => {
|
||||||
it('sends correct actions if API is success', async () => {
|
it('sends correct actions if API is success', async () => {
|
||||||
axios.post.mockResolvedValue({ data: inboxList[0] });
|
axios.post.mockResolvedValue({ data: inboxList[0] });
|
||||||
|
|||||||
@ -63,6 +63,9 @@ export default {
|
|||||||
isATelegramChannel() {
|
isATelegramChannel() {
|
||||||
return this.channelType === INBOX_TYPES.TELEGRAM;
|
return this.channelType === INBOX_TYPES.TELEGRAM;
|
||||||
},
|
},
|
||||||
|
isAVoiceChannel() {
|
||||||
|
return this.channelType === INBOX_TYPES.VOICE;
|
||||||
|
},
|
||||||
isATwilioSMSChannel() {
|
isATwilioSMSChannel() {
|
||||||
const { medium: medium = '' } = this.inbox;
|
const { medium: medium = '' } = this.inbox;
|
||||||
return this.isATwilioChannel && medium === 'sms';
|
return this.isATwilioChannel && medium === 'sms';
|
||||||
|
|||||||
@ -168,4 +168,8 @@
|
|||||||
enabled: true
|
enabled: true
|
||||||
- name: crm_integration
|
- name: crm_integration
|
||||||
display_name: CRM Integration
|
display_name: CRM Integration
|
||||||
enabled: false
|
enabled: false
|
||||||
|
- name: channel_voice
|
||||||
|
display_name: Voice Channel
|
||||||
|
enabled: false
|
||||||
|
chatwoot_internal: true
|
||||||
|
|||||||
16
db/migrate/20250620120000_create_channel_voice.rb
Normal file
16
db/migrate/20250620120000_create_channel_voice.rb
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
class CreateChannelVoice < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
create_table :channel_voice do |t|
|
||||||
|
t.string :phone_number, null: false
|
||||||
|
t.string :provider, null: false, default: 'twilio'
|
||||||
|
t.jsonb :provider_config, null: false
|
||||||
|
t.integer :account_id, null: false
|
||||||
|
t.jsonb :additional_attributes, default: {}
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :channel_voice, :phone_number, unique: true
|
||||||
|
add_index :channel_voice, :account_id
|
||||||
|
end
|
||||||
|
end
|
||||||
16
db/schema.rb
16
db/schema.rb
@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
|
ActiveRecord::Schema[7.1].define(version: 2025_06_20_120000) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@ -443,6 +443,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
|
|||||||
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
|
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "channel_voice", force: :cascade do |t|
|
||||||
|
t.string "phone_number", null: false
|
||||||
|
t.string "provider", default: "twilio", null: false
|
||||||
|
t.jsonb "provider_config", null: false
|
||||||
|
t.integer "account_id", null: false
|
||||||
|
t.jsonb "additional_attributes", default: {}
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["account_id"], name: "index_channel_voice_on_account_id"
|
||||||
|
t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
|
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
|
||||||
t.string "website_url"
|
t.string "website_url"
|
||||||
t.integer "account_id"
|
t.integer "account_id"
|
||||||
@ -907,7 +919,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
|
|||||||
t.text "header_text"
|
t.text "header_text"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.jsonb "config", default: {"allowed_locales"=>["en"]}
|
t.jsonb "config", default: {"allowed_locales" => ["en"]}
|
||||||
t.boolean "archived", default: false
|
t.boolean "archived", default: false
|
||||||
t.bigint "channel_web_widget_id"
|
t.bigint "channel_web_widget_id"
|
||||||
t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id"
|
t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id"
|
||||||
|
|||||||
@ -6,4 +6,28 @@ module Enterprise::Api::V1::Accounts::InboxesController
|
|||||||
def ee_inbox_attributes
|
def ee_inbox_attributes
|
||||||
[auto_assignment_config: [:max_assignment_limit]]
|
[auto_assignment_config: [:max_assignment_limit]]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def allowed_channel_types
|
||||||
|
super + ['voice']
|
||||||
|
end
|
||||||
|
|
||||||
|
def channel_type_from_params
|
||||||
|
case permitted_params[:channel][:type]
|
||||||
|
when 'voice'
|
||||||
|
Channel::Voice
|
||||||
|
else
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_channels_method
|
||||||
|
case permitted_params[:channel][:type]
|
||||||
|
when 'voice'
|
||||||
|
Current.account.voice_channels
|
||||||
|
else
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
64
enterprise/app/models/channel/voice.rb
Normal file
64
enterprise/app/models/channel/voice.rb
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: channel_voice
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# additional_attributes :jsonb
|
||||||
|
# phone_number :string not null
|
||||||
|
# provider :string default("twilio"), not null
|
||||||
|
# provider_config :jsonb not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# account_id :integer not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_channel_voice_on_account_id (account_id)
|
||||||
|
# index_channel_voice_on_phone_number (phone_number) UNIQUE
|
||||||
|
#
|
||||||
|
class Channel::Voice < ApplicationRecord
|
||||||
|
include Channelable
|
||||||
|
|
||||||
|
self.table_name = 'channel_voice'
|
||||||
|
|
||||||
|
validates :phone_number, presence: true, uniqueness: true
|
||||||
|
validates :provider, presence: true
|
||||||
|
validates :provider_config, presence: true
|
||||||
|
|
||||||
|
# Validate phone number format (E.164 format)
|
||||||
|
validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ }
|
||||||
|
|
||||||
|
# Provider-specific configs stored in JSON
|
||||||
|
validate :validate_provider_config
|
||||||
|
|
||||||
|
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
|
||||||
|
|
||||||
|
def name
|
||||||
|
"Voice (#{phone_number})"
|
||||||
|
end
|
||||||
|
|
||||||
|
def messaging_window_enabled?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_provider_config
|
||||||
|
return if provider_config.blank?
|
||||||
|
|
||||||
|
case provider
|
||||||
|
when 'twilio'
|
||||||
|
validate_twilio_config
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_twilio_config
|
||||||
|
config = provider_config.with_indifferent_access
|
||||||
|
required_keys = %w[account_sid auth_token api_key_sid api_key_secret]
|
||||||
|
|
||||||
|
required_keys.each do |key|
|
||||||
|
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@ -11,5 +11,6 @@ module Enterprise::Concerns::Account
|
|||||||
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
||||||
|
|
||||||
has_many :copilot_threads, dependent: :destroy_async
|
has_many :copilot_threads, dependent: :destroy_async
|
||||||
|
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
BIN
public/assets/images/dashboard/channels/voice.png
Normal file
BIN
public/assets/images/dashboard/channels/voice.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@ -22,6 +22,22 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
|||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
expect(JSON.parse(response.body)['auto_assignment_config']['max_assignment_limit']).to eq 10
|
expect(JSON.parse(response.body)['auto_assignment_config']['max_assignment_limit']).to eq 10
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'creates a voice inbox when administrator' do
|
||||||
|
post "/api/v1/accounts/#{account.id}/inboxes",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
params: { name: 'Voice Inbox',
|
||||||
|
channel: { type: 'voice', phone_number: '+15551234567',
|
||||||
|
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
|
||||||
|
auth_token: SecureRandom.hex(16),
|
||||||
|
api_key_sid: SecureRandom.hex(8),
|
||||||
|
api_key_secret: SecureRandom.hex(16) } } },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(response.body).to include('Voice Inbox')
|
||||||
|
expect(response.body).to include('+15551234567')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
60
spec/enterprise/models/channel/voice_spec.rb
Normal file
60
spec/enterprise/models/channel/voice_spec.rb
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Channel::Voice do
|
||||||
|
let(:channel) { create(:channel_voice) }
|
||||||
|
|
||||||
|
it 'has a valid factory' do
|
||||||
|
expect(channel).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'validations' do
|
||||||
|
it 'validates presence of provider_config' do
|
||||||
|
channel.provider_config = nil
|
||||||
|
expect(channel).not_to be_valid
|
||||||
|
expect(channel.errors[:provider_config]).to include("can't be blank")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates presence of account_sid in provider_config' do
|
||||||
|
channel.provider_config = { auth_token: 'token' }
|
||||||
|
expect(channel).not_to be_valid
|
||||||
|
expect(channel.errors[:provider_config]).to include('account_sid is required for Twilio provider')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates presence of auth_token in provider_config' do
|
||||||
|
channel.provider_config = { account_sid: 'sid' }
|
||||||
|
expect(channel).not_to be_valid
|
||||||
|
expect(channel.errors[:provider_config]).to include('auth_token is required for Twilio provider')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates presence of api_key_sid in provider_config' do
|
||||||
|
channel.provider_config = { account_sid: 'sid', auth_token: 'token' }
|
||||||
|
expect(channel).not_to be_valid
|
||||||
|
expect(channel.errors[:provider_config]).to include('api_key_sid is required for Twilio provider')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates presence of api_key_secret in provider_config' do
|
||||||
|
channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key' }
|
||||||
|
expect(channel).not_to be_valid
|
||||||
|
expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is valid with all required provider_config fields' do
|
||||||
|
channel.provider_config = {
|
||||||
|
account_sid: 'test_sid',
|
||||||
|
auth_token: 'test_token',
|
||||||
|
api_key_sid: 'test_key',
|
||||||
|
api_key_secret: 'test_secret'
|
||||||
|
}
|
||||||
|
expect(channel).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#name' do
|
||||||
|
it 'returns Voice with phone number' do
|
||||||
|
expect(channel.name).to include('Voice')
|
||||||
|
expect(channel.name).to include(channel.phone_number)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
20
spec/factories/channel/channel_voice.rb
Normal file
20
spec/factories/channel/channel_voice.rb
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :channel_voice, class: 'Channel::Voice' do
|
||||||
|
sequence(:phone_number) { |n| "+155512345#{n.to_s.rjust(2, '0')}" }
|
||||||
|
provider_config do
|
||||||
|
{
|
||||||
|
account_sid: "AC#{SecureRandom.hex(16)}",
|
||||||
|
auth_token: SecureRandom.hex(16),
|
||||||
|
api_key_sid: SecureRandom.hex(8),
|
||||||
|
api_key_secret: SecureRandom.hex(16)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
account
|
||||||
|
|
||||||
|
after(:create) do |channel_voice|
|
||||||
|
create(:inbox, channel: channel_voice, account: channel_voice.account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user