diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 61d16b2ca..e7b3b197b 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -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 diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index 9c0a24925..36dd6216e 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -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 = { diff --git a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js index df30d7138..5860e30ea 100644 --- a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js +++ b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js @@ -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' }; diff --git a/app/javascript/dashboard/components-next/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue index ea6eb0417..ed6d7a20b 100644 --- a/app/javascript/dashboard/components-next/input/Input.vue +++ b/app/javascript/dashboard/components-next/input/Input.vue @@ -1,51 +1,21 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue index b7b0a2c1d..4ff8b1ab1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue @@ -29,6 +29,7 @@ const i18nMap = { 'Channel::Line': 'LINE', 'Channel::Api': 'API', 'Channel::Instagram': 'INSTAGRAM', + 'Channel::Voice': 'VOICE', }; const twilioChannelName = () => { diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index 32d91fb8e..aef95d1b1 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -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 { diff --git a/app/javascript/dashboard/store/modules/inboxes/channelActions.js b/app/javascript/dashboard/store/modules/inboxes/channelActions.js new file mode 100644 index 000000000..9975d8d1f --- /dev/null +++ b/app/javascript/dashboard/store/modules/inboxes/channelActions.js @@ -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; + } + }, +}; diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js index 8e917467a..a91addaa3 100644 --- a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js @@ -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] }); diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js index 273e9f8b4..bf8ec492b 100644 --- a/app/javascript/shared/mixins/inboxMixin.js +++ b/app/javascript/shared/mixins/inboxMixin.js @@ -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'; diff --git a/config/features.yml b/config/features.yml index 131456c72..95f7e33d4 100644 --- a/config/features.yml +++ b/config/features.yml @@ -168,4 +168,8 @@ enabled: true - name: crm_integration display_name: CRM Integration - enabled: false \ No newline at end of file + enabled: false +- name: channel_voice + display_name: Voice Channel + enabled: false + chatwoot_internal: true diff --git a/db/migrate/20250620120000_create_channel_voice.rb b/db/migrate/20250620120000_create_channel_voice.rb new file mode 100644 index 000000000..9e2a25723 --- /dev/null +++ b/db/migrate/20250620120000_create_channel_voice.rb @@ -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 \ No newline at end of file diff --git a/db/schema.rb b/db/schema.rb index d065a8ff4..fdd0cce82 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb index b39db609d..396c3a91d 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb @@ -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 diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb new file mode 100644 index 000000000..a313129e2 --- /dev/null +++ b/enterprise/app/models/channel/voice.rb @@ -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 + diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 4a573a4c4..c31b6c10e 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -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 diff --git a/public/assets/images/dashboard/channels/voice.png b/public/assets/images/dashboard/channels/voice.png new file mode 100644 index 000000000..7c9481faf Binary files /dev/null and b/public/assets/images/dashboard/channels/voice.png differ diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb index 724a7b0cb..498c42abd 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb @@ -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 diff --git a/spec/enterprise/models/channel/voice_spec.rb b/spec/enterprise/models/channel/voice_spec.rb new file mode 100644 index 000000000..2e52807b0 --- /dev/null +++ b/spec/enterprise/models/channel/voice_spec.rb @@ -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 diff --git a/spec/factories/channel/channel_voice.rb b/spec/factories/channel/channel_voice.rb new file mode 100644 index 000000000..33be75f2e --- /dev/null +++ b/spec/factories/channel/channel_voice.rb @@ -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