diff --git a/Gemfile.lock b/Gemfile.lock index 6b7848b2f..c728e7b4d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -172,6 +172,8 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) + childprocess (5.1.0) + logger (~> 1.5) climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) @@ -433,10 +435,12 @@ GEM json (>= 1.8) rexml language_server-protocol (3.17.0.5) - launchy (2.5.2) + launchy (3.1.1) addressable (~> 2.8) - letter_opener (1.8.1) - launchy (>= 2.2, < 3) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) line-bot-api (1.28.0) lint_roller (1.1.0) liquid (5.4.0) @@ -566,7 +570,7 @@ GEM method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (6.0.0) + public_suffix (6.0.2) puma (6.4.3) nio4r (~> 2.0) pundit (2.3.0) 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/controllers/api/v1/accounts/integrations/notion_controller.rb b/app/controllers/api/v1/accounts/integrations/notion_controller.rb new file mode 100644 index 000000000..dff6ccece --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/notion_controller.rb @@ -0,0 +1,14 @@ +class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController + before_action :fetch_hook, only: [:destroy] + + def destroy + @hook.destroy! + head :ok + end + + private + + def fetch_hook + @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion') + end +end \ No newline at end of file diff --git a/app/controllers/api/v1/accounts/notion/authorizations_controller.rb b/app/controllers/api/v1/accounts/notion/authorizations_controller.rb new file mode 100644 index 000000000..bb9b2f858 --- /dev/null +++ b/app/controllers/api/v1/accounts/notion/authorizations_controller.rb @@ -0,0 +1,21 @@ +class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController + include NotionConcern + + def create + redirect_url = notion_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/notion/callback", + response_type: 'code', + owner: 'user', + state: state, + client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil) + } + ) + + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end +end \ No newline at end of file diff --git a/app/controllers/concerns/notion_concern.rb b/app/controllers/concerns/notion_concern.rb new file mode 100644 index 000000000..2b94fe63b --- /dev/null +++ b/app/controllers/concerns/notion_concern.rb @@ -0,0 +1,21 @@ +module NotionConcern + extend ActiveSupport::Concern + + def notion_client + app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil) + app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil) + + ::OAuth2::Client.new(app_id, app_secret, { + site: 'https://api.notion.com', + authorize_url: 'https://api.notion.com/v1/oauth/authorize', + token_url: 'https://api.notion.com/v1/oauth/token', + auth_scheme: :basic_auth + }) + end + + private + + def scope + '' + end +end diff --git a/app/controllers/notion/callbacks_controller.rb b/app/controllers/notion/callbacks_controller.rb new file mode 100644 index 000000000..94030fc8e --- /dev/null +++ b/app/controllers/notion/callbacks_controller.rb @@ -0,0 +1,36 @@ +class Notion::CallbacksController < OauthCallbackController + include NotionConcern + + private + + def provider_name + 'notion' + end + + def oauth_client + notion_client + end + + def handle_response + hook = account.hooks.new( + access_token: parsed_body['access_token'], + status: 'enabled', + app_id: 'notion', + settings: { + token_type: parsed_body['token_type'], + workspace_name: parsed_body['workspace_name'], + workspace_id: parsed_body['workspace_id'], + workspace_icon: parsed_body['workspace_icon'], + bot_id: parsed_body['bot_id'], + owner: parsed_body['owner'] + } + ) + + hook.save! + redirect_to notion_redirect_uri + end + + def notion_redirect_uri + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion" + end +end \ No newline at end of file diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 32a147d34..02023559b 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -7,7 +7,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def index @articles = @portal.articles.published.includes(:category, :author) + + @articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present? + @articles_count = @articles.count + search_articles order_by_sort_param limit_results diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 668114d35..0601beba7 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -39,8 +39,9 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], - 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], 'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET] + 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], + 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] } @allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]) diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index ff16e9386..455828228 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -27,7 +27,10 @@ class Twilio::CallbackController < ApplicationController *Array.new(10) { |i| :"MediaUrl#{i}" }, *Array.new(10) { |i| :"MediaContentType#{i}" }, :MessagingServiceSid, - :NumMedia + :NumMedia, + :Latitude, + :Longitude, + :MessageType ) end end diff --git a/app/javascript/dashboard/api/notion_auth.js b/app/javascript/dashboard/api/notion_auth.js new file mode 100644 index 000000000..8a0027f9b --- /dev/null +++ b/app/javascript/dashboard/api/notion_auth.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class NotionOAuthClient extends ApiClient { + constructor() { + super('notion', { accountScoped: true }); + } + + generateAuthorization() { + return axios.post(`${this.url}/authorization`); + } +} + +export default new NotionOAuthClient(); diff --git a/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue index f00354105..7879411c8 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue @@ -123,7 +123,7 @@ const handleDocumentableClick = () => { @mouseenter="emit('hover', true)" @mouseleave="emit('hover', false)" > -
+
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/routes/dashboard/settings/integrations/Notion.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Notion.vue new file mode 100644 index 000000000..c2d63ad22 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Notion.vue @@ -0,0 +1,80 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js index 2cbeaa0ea..a76e82730 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js @@ -8,6 +8,7 @@ import DashboardApps from './DashboardApps/Index.vue'; import Slack from './Slack.vue'; import SettingsContent from '../Wrapper.vue'; import Linear from './Linear.vue'; +import Notion from './Notion.vue'; import Shopify from './Shopify.vue'; import Github from './Github.vue'; export default { @@ -90,15 +91,24 @@ export default { }, props: route => ({ code: route.query.code }), }, + { + path: 'notion', + name: 'settings_integrations_notion', + component: Notion, + meta: { + permissions: ['administrator'], + }, + props: route => ({ code: route.query.code }), + }, { path: 'github', name: 'settings_integrations_github', - component: Github, + component: Shopify, meta: { featureFlag: FEATURE_FLAGS.INTEGRATIONS, permissions: ['administrator'], }, - props: route => ({ code: route.query.code }), + props: route => ({ error: route.query.error }), }, { path: 'shopify', 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/portal/portalHelpers.js b/app/javascript/portal/portalHelpers.js index 35e54d154..1fdc51276 100644 --- a/app/javascript/portal/portalHelpers.js +++ b/app/javascript/portal/portalHelpers.js @@ -112,11 +112,14 @@ export const InitializationHelpers = { }, setDirectionAttribute: () => { - const portalElement = document.getElementById('portal'); - if (!portalElement) return; + const htmlElement = document.querySelector('html'); + // If direction is already applied through props, do not apply again (iframe case) + const hasDirApplied = htmlElement.getAttribute('data-dir-applied'); + if (!htmlElement || hasDirApplied) return; - const locale = document.querySelector('.locale-switcher')?.value; - portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr'; + const localeFromHtml = htmlElement.lang; + htmlElement.dir = + localeFromHtml && getLanguageDirection(localeFromHtml) ? 'rtl' : 'ltr'; }, initializeThemesInPortal: initializeTheme, diff --git a/app/javascript/shared/components/IframeLoader.vue b/app/javascript/shared/components/IframeLoader.vue index b3c91b8f0..0a99816b4 100644 --- a/app/javascript/shared/components/IframeLoader.vue +++ b/app/javascript/shared/components/IframeLoader.vue @@ -1,64 +1,56 @@ - @@ -66,6 +58,7 @@ export default {