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:
Sojan Jose
2025-06-25 14:21:03 -07:00
committed by GitHub
parent 97efd36bc5
commit b7f2c151bf
27 changed files with 584 additions and 72 deletions

View File

@@ -81,11 +81,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
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))
end
def allowed_channel_types
%w[web_widget api email line telegram whatsapp sms]
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end

View File

@@ -13,6 +13,7 @@ export function useChannelIcon(inbox) {
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
'Channel::Voice': 'i-ri-phone-fill',
};
const providerIconMap = {

View File

@@ -19,6 +19,12 @@ describe('useChannelIcon', () => {
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', () => {
it('returns mail icon for generic email channel', () => {
const inbox = { channel_type: 'Channel::Email' };

View File

@@ -1,51 +1,21 @@
<script setup>
import { computed, ref, onMounted, nextTick } from 'vue';
import { computed, ref, onMounted, nextTick, getCurrentInstance } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
type: {
type: String,
default: 'text',
},
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,
},
modelValue: { type: [String, Number], default: '' },
type: { type: String, default: 'text' },
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: {
type: String,
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
min: {
type: String,
default: '',
},
autofocus: {
type: Boolean,
default: false,
},
min: { type: String, default: '' },
autofocus: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -56,6 +26,10 @@ const emit = defineEmits([
'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 inputRef = ref(null);
@@ -111,7 +85,7 @@ onMounted(() => {
<div class="relative flex flex-col min-w-0 gap-1">
<label
v-if="label"
:for="id"
:for="uniqueId"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ label }}
@@ -119,7 +93,7 @@ onMounted(() => {
<!-- Added prefix slot to allow adding icons to the input -->
<slot name="prefix" />
<input
:id="id"
:id="uniqueId"
ref="inputRef"
:value="modelValue"
:class="[

View File

@@ -17,7 +17,7 @@ export default {
<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"
>
<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
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
>

View File

@@ -41,6 +41,10 @@ export default {
);
}
if (key === 'voice') {
return this.enabledFeatures.channel_voice;
}
return [
'website',
'twilio',
@@ -50,6 +54,7 @@ export default {
'telegram',
'line',
'instagram',
'voice',
].includes(key);
},
},

View File

@@ -125,6 +125,10 @@ export const useInbox = () => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
return {
inbox,
isAFacebookInbox,
@@ -142,5 +146,6 @@ export const useInbox = () => {
is360DialogWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isAVoiceChannel,
};
};

View File

@@ -10,6 +10,7 @@ export const INBOX_TYPES = {
LINE: 'Channel::Line',
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
VOICE: 'Channel::Voice',
};
const INBOX_ICON_MAP_FILL = {
@@ -22,6 +23,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-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.LINE]: 'i-ri-line-line',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
[INBOX_TYPES.VOICE]: 'i-ri-phone-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.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
@@ -85,6 +89,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default:
return 'chat';
}
@@ -124,6 +131,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.INSTAGRAM:
return 'brand-instagram';
case INBOX_TYPES.VOICE:
return 'phone';
default:
return 'chat';
}

View File

@@ -268,6 +268,46 @@
"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": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.",
@@ -789,7 +829,8 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}

View File

@@ -10,6 +10,7 @@ import Whatsapp from './channels/Whatsapp.vue';
import Line from './channels/Line.vue';
import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue';
import Voice from './channels/Voice.vue';
const channelViewList = {
facebook: Facebook,
@@ -22,6 +23,7 @@ const channelViewList = {
line: Line,
telegram: Telegram,
instagram: Instagram,
voice: Voice,
};
export default defineComponent({

View File

@@ -36,6 +36,7 @@ export default {
{ key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' },
{ key: 'instagram', name: 'Instagram' },
{ key: 'voice', name: 'Voice' },
];
},
...mapGetters({

View File

@@ -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>

View File

@@ -29,6 +29,7 @@ const i18nMap = {
'Channel::Line': 'LINE',
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Voice': 'VOICE',
};
const twilioChannelName = () => {

View File

@@ -9,29 +9,7 @@ import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
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;
};
import { channelActions, buildInboxData } from './inboxes/channelActions';
export const state = {
records: [],
@@ -220,6 +198,12 @@ export const actions = {
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 }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
try {

View File

@@ -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;
}
},
};

View File

@@ -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', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] });

View File

@@ -63,6 +63,9 @@ export default {
isATelegramChannel() {
return this.channelType === INBOX_TYPES.TELEGRAM;
},
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
},
isATwilioSMSChannel() {
const { medium: medium = '' } = this.inbox;
return this.isATwilioChannel && medium === 'sms';

View File

@@ -169,3 +169,7 @@
- name: crm_integration
display_name: CRM Integration
enabled: false
- name: channel_voice
display_name: Voice Channel
enabled: false
chatwoot_internal: true

View 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

View File

@@ -10,7 +10,7 @@
#
# 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
enable_extension "pg_stat_statements"
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
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|
t.string "website_url"
t.integer "account_id"
@@ -907,7 +919,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.text "header_text"
t.datetime "created_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.bigint "channel_web_widget_id"
t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id"

View File

@@ -6,4 +6,28 @@ module Enterprise::Api::V1::Accounts::InboxesController
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
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

View 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

View File

@@ -11,5 +11,6 @@ module Enterprise::Concerns::Account
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
has_many :copilot_threads, dependent: :destroy_async
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
end
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -22,6 +22,22 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body)['auto_assignment_config']['max_assignment_limit']).to eq 10
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

View 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

View 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