From b26862e3d8b68c9383666be1160b13d645ddbad2 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 26 Jun 2025 19:16:06 +0530 Subject: [PATCH] feat: notion OAuth setup (#11765) Co-authored-by: Muhsin Keloth --- .../integrations/notion_controller.rb | 14 +++ .../notion/authorizations_controller.rb | 21 ++++ app/controllers/concerns/notion_concern.rb | 21 ++++ .../notion/callbacks_controller.rb | 36 ++++++ .../super_admin/app_configs_controller.rb | 1 + app/javascript/dashboard/api/notion_auth.js | 14 +++ .../i18n/locale/en/integrations.json | 8 ++ .../settings/integrations/Notion.vue | 80 +++++++++++++ .../integrations/integrations.routes.js | 10 ++ app/models/integrations/app.rb | 14 ++- app/models/integrations/hook.rb | 4 + .../super_admin/application/_icons.html.erb | 5 + config/features.yml | 3 + config/installation_config.yml | 19 +++ config/integration/apps.yml | 6 + config/locales/en.yml | 4 + config/routes.rb | 10 ++ .../app/helpers/super_admin/features.yml | 6 + .../images/integrations/notion-dark.png | Bin 0 -> 20124 bytes .../dashboard/images/integrations/notion.png | Bin 0 -> 24494 bytes .../notion/authorization_controller_spec.rb | 53 +++++++++ .../concerns/notion_concern_spec.rb | 56 +++++++++ .../notion/callbacks_controller_spec.rb | 112 ++++++++++++++++++ 23 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/v1/accounts/integrations/notion_controller.rb create mode 100644 app/controllers/api/v1/accounts/notion/authorizations_controller.rb create mode 100644 app/controllers/concerns/notion_concern.rb create mode 100644 app/controllers/notion/callbacks_controller.rb create mode 100644 app/javascript/dashboard/api/notion_auth.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrations/Notion.vue create mode 100644 public/dashboard/images/integrations/notion-dark.png create mode 100644 public/dashboard/images/integrations/notion.png create mode 100644 spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb create mode 100644 spec/controllers/concerns/notion_concern_spec.rb create mode 100644 spec/controllers/notion/callbacks_controller_spec.rb 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/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 204bfc95b..771f9f28c 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -39,6 +39,7 @@ 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], + '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] } 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/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 41f63d0a2..b3e091722 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -328,6 +328,14 @@ "DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.", "BUTTON_TEXT": "Connect Linear workspace" } + }, + "NOTION": { + "DELETE": { + "TITLE": "Are you sure you want to delete the Notion integration?", + "MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.", + "CONFIRM": "Yes, delete", + "CANCEL": "Cancel" + } } }, "CAPTAIN": { 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 e50eccb3a..bb700fc74 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'; export default { @@ -90,6 +91,15 @@ 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: 'shopify', name: 'settings_integrations_shopify', diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb index dfe889bfa..3b5cd821a 100644 --- a/app/models/integrations/app.rb +++ b/app/models/integrations/app.rb @@ -55,9 +55,11 @@ class Integrations::App when 'linear' GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present? when 'shopify' - account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present? + shopify_enabled?(account) when 'leadsquared' account.feature_enabled?('crm_integration') + when 'notion' + notion_enabled?(account) else true end @@ -113,4 +115,14 @@ class Integrations::App all.detect { |app| app.id == params[:id] } end end + + private + + def shopify_enabled?(account) + account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present? + end + + def notion_enabled?(account) + account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present? + end end diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index e7300b525..ca77fa13d 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -53,6 +53,10 @@ class Integrations::Hook < ApplicationRecord app_id == 'dialogflow' end + def notion? + app_id == 'notion' + end + def disable update(status: 'disabled') end diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb index fabff914b..6669fe87d 100644 --- a/app/views/super_admin/application/_icons.html.erb +++ b/app/views/super_admin/application/_icons.html.erb @@ -156,9 +156,14 @@ + + + + + diff --git a/config/features.yml b/config/features.yml index 95f7e33d4..5171b1c01 100644 --- a/config/features.yml +++ b/config/features.yml @@ -173,3 +173,6 @@ display_name: Voice Channel enabled: false chatwoot_internal: true +- name: notion_integration + display_name: Notion Integration + enabled: false diff --git a/config/installation_config.yml b/config/installation_config.yml index 66538d4ab..a089a4ea7 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -288,6 +288,25 @@ type: secret ## ------ End of Configs added for Linear ------ ## +## ------ Configs added for Notion ------ ## +- name: NOTION_CLIENT_ID + display_title: 'Notion Client ID' + value: + locked: false + description: 'Notion client ID' +- name: NOTION_CLIENT_SECRET + display_title: 'Notion Client Secret' + value: + locked: false + description: 'Notion client secret' + type: secret +- name: NOTION_VERSION + display_title: 'Notion Version' + value: '2022-06-28' + locked: false + description: 'Notion version' +## ------ End of Configs added for Notion ------ ## + ## ------ Configs added for Slack ------ ## - name: SLACK_CLIENT_ID display_title: 'Slack Client ID' diff --git a/config/integration/apps.yml b/config/integration/apps.yml index 1faf35670..dd5c722a4 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -63,6 +63,12 @@ linear: action: https://linear.app/oauth/authorize hook_type: account allow_multiple_hooks: false +notion: + id: notion + logo: notion.png + i18n_key: notion + hook_type: account + allow_multiple_hooks: false slack: id: slack logo: slack.png diff --git a/config/locales/en.yml b/config/locales/en.yml index 181a9399f..a41a009cf 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -257,6 +257,10 @@ en: name: 'Linear' short_description: 'Create and link Linear issues directly from conversations.' description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.' + notion: + name: 'Notion' + short_description: 'Integrate databases, documents and pages directly with Captain.' + description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.' shopify: name: 'Shopify' short_description: 'Access order details and customer data from your Shopify store.' diff --git a/config/routes.rb b/config/routes.rb index 203a954d1..41ee52f42 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -228,6 +228,10 @@ Rails.application.routes.draw do resource :authorization, only: [:create] end + namespace :notion do + resource :authorization, only: [:create] + end + resources :webhooks, only: [:index, :create, :update, :destroy] namespace :integrations do resources :apps, only: [:index, :show] @@ -265,6 +269,11 @@ Rails.application.routes.draw do get :linked_issues end end + resource :notion, controller: 'notion', only: [] do + collection do + delete :destroy + end + end end resources :working_hours, only: [:update] @@ -493,6 +502,7 @@ Rails.application.routes.draw do get 'microsoft/callback', to: 'microsoft/callbacks#show' get 'google/callback', to: 'google/callbacks#show' get 'instagram/callback', to: 'instagram/callbacks#show' + get 'notion/callback', to: 'notion/callbacks#show' # ---------------------------------------------------------------------- # Routes for external service verifications get '.well-known/assetlinks.json' => 'android_app#assetlinks' diff --git a/enterprise/app/helpers/super_admin/features.yml b/enterprise/app/helpers/super_admin/features.yml index c20de2dfa..e86f66832 100644 --- a/enterprise/app/helpers/super_admin/features.yml +++ b/enterprise/app/helpers/super_admin/features.yml @@ -91,6 +91,12 @@ linear: enabled: true icon: 'icon-linear' config_key: 'linear' +notion: + name: 'Notion' + description: 'Configuration for setting up Notion Integration' + enabled: true + icon: 'icon-notion' + config_key: 'notion' slack: name: 'Slack' description: 'Configuration for setting up Slack Integration' diff --git a/public/dashboard/images/integrations/notion-dark.png b/public/dashboard/images/integrations/notion-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..7d15c715e6026de6eb112794b4cd5f1248c82b33 GIT binary patch literal 20124 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 literal 0 HcmV?d00001 diff --git a/public/dashboard/images/integrations/notion.png b/public/dashboard/images/integrations/notion.png new file mode 100644 index 0000000000000000000000000000000000000000..a358e8a51b8d80ce8a1599118bd6bc7ef0986393 GIT binary patch literal 24494 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 B7)SsB literal 0 HcmV?d00001 diff --git a/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb new file mode 100644 index 000000000..ac4bc2841 --- /dev/null +++ b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe 'Notion Authorization API', type: :request do + let(:account) { create(:account) } + + describe 'POST /api/v1/accounts/{account.id}/notion/authorization' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/notion/authorization" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + it 'returns unauthorized for agent' do + post "/api/v1/accounts/#{account.id}/notion/authorization", + headers: agent.create_new_auth_token, + params: { email: administrator.email }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'creates a new authorization and returns the redirect url' do + post "/api/v1/accounts/#{account.id}/notion/authorization", + headers: administrator.create_new_auth_token, + params: { email: administrator.email }, + as: :json + + expect(response).to have_http_status(:success) + + # Validate URL components + url = response.parsed_body['url'] + uri = URI.parse(url) + params = CGI.parse(uri.query) + + expect(url).to start_with('https://api.notion.com/v1/oauth/authorize') + expect(params['response_type']).to eq(['code']) + expect(params['owner']).to eq(['user']) + expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/notion/callback"]) + + # Validate state parameter exists and can be decoded back to the account + expect(params['state']).to be_present + decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default') + expect(decoded_account).to eq(account) + end + end + end +end \ No newline at end of file diff --git a/spec/controllers/concerns/notion_concern_spec.rb b/spec/controllers/concerns/notion_concern_spec.rb new file mode 100644 index 000000000..7ae11b17d --- /dev/null +++ b/spec/controllers/concerns/notion_concern_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +RSpec.describe NotionConcern, type: :concern do + let(:controller_class) do + Class.new do + include NotionConcern + end + end + + let(:controller) { controller_class.new } + + describe '#notion_client' do + let(:client_id) { 'test_notion_client_id' } + let(:client_secret) { 'test_notion_client_secret' } + + before do + allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil).and_return(client_id) + allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil).and_return(client_secret) + end + + it 'creates OAuth2 client with correct configuration' do + expect(OAuth2::Client).to receive(:new).with( + client_id, + client_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 + } + ) + + controller.notion_client + end + + it 'loads client credentials from GlobalConfigService' do + expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil) + expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil) + + controller.notion_client + end + + it 'returns OAuth2::Client instance' do + client = controller.notion_client + expect(client).to be_an_instance_of(OAuth2::Client) + end + + it 'configures client with Notion-specific endpoints' do + client = controller.notion_client + expect(client.site).to eq('https://api.notion.com') + expect(client.options[:authorize_url]).to eq('https://api.notion.com/v1/oauth/authorize') + expect(client.options[:token_url]).to eq('https://api.notion.com/v1/oauth/token') + expect(client.options[:auth_scheme]).to eq(:basic_auth) + end + end +end diff --git a/spec/controllers/notion/callbacks_controller_spec.rb b/spec/controllers/notion/callbacks_controller_spec.rb new file mode 100644 index 000000000..045bbe396 --- /dev/null +++ b/spec/controllers/notion/callbacks_controller_spec.rb @@ -0,0 +1,112 @@ +require 'rails_helper' + +RSpec.describe Notion::CallbacksController, type: :request do + let(:account) { create(:account) } + let(:state) { account.to_sgid.to_s } + let(:oauth_code) { 'test_oauth_code' } + let(:notion_redirect_uri) { "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion" } + + let(:notion_response_body) do + { + 'access_token' => 'notion_access_token_123', + 'token_type' => 'bearer', + 'workspace_name' => 'Test Workspace', + 'workspace_id' => 'workspace_123', + 'workspace_icon' => 'https://notion.so/icon.png', + 'bot_id' => 'bot_123', + 'owner' => { + 'type' => 'user', + 'user' => { + 'id' => 'user_123', + 'name' => 'Test User' + } + } + } + end + + describe 'GET /notion/callback' do + before do + account.enable_features('notion_integration') + stub_const('ENV', ENV.to_hash.merge( + 'FRONTEND_URL' => 'http://localhost:3000', + 'NOTION_CLIENT_ID' => 'test_client_id', + 'NOTION_CLIENT_SECRET' => 'test_client_secret' + )) + + controller = described_class.new + allow(controller).to receive(:account).and_return(account) + allow(controller).to receive(:notion_redirect_uri).and_return(notion_redirect_uri) + allow(described_class).to receive(:new).and_return(controller) + end + + context 'when OAuth callback is successful' do + before do + stub_request(:post, 'https://api.notion.com/v1/oauth/token') + .to_return( + status: 200, + body: notion_response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'creates a new integration hook' do + expect do + get '/notion/callback', params: { code: oauth_code, state: state } + end.to change(Integrations::Hook, :count).by(1) + + hook = Integrations::Hook.last + expect(hook.access_token).to eq('notion_access_token_123') + expect(hook.app_id).to eq('notion') + expect(hook.status).to eq('enabled') + end + + it 'sets correct hook attributes' do + get '/notion/callback', params: { code: oauth_code, state: state } + + hook = Integrations::Hook.last + expect(hook.account).to eq(account) + expect(hook.app_id).to eq('notion') + expect(hook.access_token).to eq('notion_access_token_123') + expect(hook.status).to eq('enabled') + end + + it 'stores notion workspace data in settings' do + get '/notion/callback', params: { code: oauth_code, state: state } + + hook = Integrations::Hook.last + expect(hook.settings['token_type']).to eq('bearer') + expect(hook.settings['workspace_name']).to eq('Test Workspace') + expect(hook.settings['workspace_id']).to eq('workspace_123') + expect(hook.settings['workspace_icon']).to eq('https://notion.so/icon.png') + expect(hook.settings['bot_id']).to eq('bot_123') + expect(hook.settings['owner']).to eq(notion_response_body['owner']) + end + + it 'handles successful callback and creates hook' do + get '/notion/callback', params: { code: oauth_code, state: state } + + # Due to controller mocking limitations in test, + # the redirect URL construction fails but hook creation succeeds + expect(Integrations::Hook.last.app_id).to eq('notion') + expect(response).to be_redirect + end + end + + context 'when OAuth token request fails' do + before do + stub_request(:post, 'https://api.notion.com/v1/oauth/token') + .to_return( + status: 400, + body: { error: 'invalid_grant' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'redirects to home page on error' do + get '/notion/callback', params: { code: oauth_code, state: state } + + expect(response).to redirect_to('/') + end + end + end +end