Compare commits

...
Author SHA1 Message Date
Muhsin KelothandGitHub 141595977a Merge branch 'feat/notion-oauth' into feat/notion-api 2025-06-24 15:52:41 +05:30
Muhsin KelothandGitHub 8a39c4957a Merge branch 'develop' into feat/notion-oauth 2025-06-24 13:32:40 +05:30
Shivam MishraandGitHub 2f1f734b26 Merge branch 'develop' into feat/notion-oauth 2025-06-23 20:58:00 +05:30
Shivam Mishra 2f1b8f1580 chore: update copy 2025-06-23 20:56:05 +05:30
Shivam Mishra b58f462b88 feat: notion feature flag 2025-06-23 20:48:39 +05:30
Shivam Mishra afc5a9539c feat: add notion controller 2025-06-23 20:46:43 +05:30
Shivam Mishra 04b180b572 chore: run migration 2025-06-18 21:40:38 +05:30
Shivam Mishra c25b927d59 feat: add max crawl depth 2025-06-18 21:39:47 +05:30
Shivam Mishra 5e4fd23cd5 feat: add notion crawling
This crawling is only one level deep
2025-06-18 21:30:45 +05:30
Shivam Mishra 1c19ef0940 feat: include URL in presenter 2025-06-18 21:30:27 +05:30
Shivam Mishra 7207fda7b8 feat: update document doc 2025-06-18 21:11:43 +05:30
Shivam Mishra 5bf4eb0b48 feat: update processor service 2025-06-18 19:59:38 +05:30
Shivam Mishra c5e27e008d test: notion presenter 2025-06-18 19:59:26 +05:30
Shivam Mishra 3bd06fd2c0 refactor: separate presenter 2025-06-18 19:52:52 +05:30
Shivam Mishra 443f68bff6 feat: extract child pages 2025-06-18 19:50:08 +05:30
Shivam Mishra 7a5a450577 feat: format response 2025-06-18 19:44:21 +05:30
Shivam Mishra aa94b17e69 feat: add notion controller 2025-06-18 19:44:16 +05:30
Shivam Mishra 5604e59059 test: processor service 2025-06-18 19:25:57 +05:30
Shivam Mishra 1bdd045417 style: rubocop fixes 2025-06-18 19:25:57 +05:30
Shivam Mishra 3f515d103d feat: notion processor service 2025-06-18 19:25:57 +05:30
Shivam Mishra 94453c8e8b test: page blocks end point 2025-06-18 19:25:57 +05:30
Shivam Mishra 62dbf65f99 feat: allow converting a page to markdown 2025-06-18 19:25:57 +05:30
Shivam Mishra 7d66691844 feat: add page blocks endpoint 2025-06-18 19:25:57 +05:30
Shivam Mishra 6b5e4641ba feat: update auth 2025-06-18 19:25:56 +05:30
Shivam Mishra a5a4b15e5b feat: add notion API 2025-06-18 19:25:56 +05:30
Shivam Mishra a9224a6c67 feat: add Notion version 2025-06-18 19:25:56 +05:30
Shivam Mishra 5a457305f7 feat: add notion to allowed config 2025-06-18 19:25:56 +05:30
Shivam Mishra 2ec1f4645e feat: notion settings 2025-06-18 19:25:56 +05:30
Shivam Mishra bc67705982 feat: add notion page 2025-06-18 19:25:56 +05:30
Shivam Mishra af5edcf4db feat: add notion oauth flow 2025-06-18 19:25:56 +05:30
Shivam Mishra a3333eae9c feat: add notion concern 2025-06-18 19:25:56 +05:30
Shivam Mishra f4b6ad6c90 feat: add notion integration app 2025-06-18 19:25:56 +05:30
38 changed files with 1988 additions and 8 deletions
@@ -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
@@ -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
@@ -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
'read'
end
end
@@ -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
@@ -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]
}
@@ -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();
@@ -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": {
@@ -0,0 +1,80 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import ButtonNext from 'next/button/Button.vue';
import notionClient from 'dashboard/api/notion_auth.js';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
const { t } = useI18n();
const store = useStore();
const integrationLoaded = ref(false);
const integration = useFunctionGetter('integrations/getIntegration', 'notion');
const uiFlags = useMapGetter('integrations/getUIFlags');
const integrationAction = computed(() => {
if (integration.value.enabled) {
return 'disconnect';
}
return '';
});
const authorize = async () => {
const response = await notionClient.generateAuthorization();
const {
data: { url },
} = response;
window.location.href = url;
};
const initializeNotionIntegration = async () => {
await store.dispatch('integrations/get', 'notion');
integrationLoaded.value = true;
};
onMounted(() => {
initializeNotionIntegration();
});
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto mx-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingNotion">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: t('INTEGRATION_SETTINGS.NOTION.DELETE.TITLE'),
message: t('INTEGRATION_SETTINGS.NOTION.DELETE.MESSAGE'),
}"
>
<template #action>
<ButtonNext
faded
blue
:label="t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT')"
@click="authorize"
/>
</template>
</Integration>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>
@@ -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',
+2
View File
@@ -58,6 +58,8 @@ class Integrations::App
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
when 'leadsquared'
account.feature_enabled?('crm_integration')
when 'notion'
account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present?
else
true
end
+4
View File
@@ -53,6 +53,10 @@ class Integrations::Hook < ApplicationRecord
app_id == 'dialogflow'
end
def notion?
app_id == 'notion'
end
def disable
update(status: 'disabled')
end
+61
View File
@@ -0,0 +1,61 @@
class NotionPagePresenter
def initialize(page_response, blocks_response)
@page_response = page_response
@blocks_response = blocks_response
end
def to_hash
{
'id' => @page_response['id'],
'icon' => @page_response['icon'],
'title' => extract_page_title,
'url' => @page_response['url'],
'created_time' => @page_response['created_time'],
'last_edited_time' => @page_response['last_edited_time'],
'md' => generate_markdown,
'child_pages' => extract_child_pages
}
end
private
def extract_page_title
# Try to get title from properties (for database pages)
if @page_response['properties']
title_property = @page_response['properties'].values.find { |prop| prop['type'] == 'title' }
return title_property['title'].map { |t| t['plain_text'] }.join if title_property && title_property['title']&.any?
end
nil
end
def generate_markdown
title = extract_page_title
content_md = NotionToMarkdown.new.convert(@blocks_response['results'])
title_md = title ? "# #{title}\n\n" : ''
"#{title_md}#{content_md}"
end
def extract_child_pages
extract_child_pages_from_blocks(@blocks_response['results'])
end
def extract_child_pages_from_blocks(blocks)
child_pages = []
blocks.each do |block|
if block['type'] == 'child_page'
child_pages << {
'id' => block['id'],
'title' => block['child_page']['title']
}
end
# Recursively check nested blocks
child_pages.concat(extract_child_pages_from_blocks(block['children'])) if block['has_children'] && block['children']
end
child_pages
end
end
+104
View File
@@ -0,0 +1,104 @@
class NotionToMarkdown
# Main entry: pass in the Notion page blocks (array of block hashes)
def convert(blocks)
blocks.map { |block| block_to_markdown(block) }.join("\n\n")
end
private
def block_to_markdown(block, depth = 0)
type = block['type']
return '' unless type
case type
when 'paragraph'
rich_text_to_md(block['paragraph']['rich_text'])
when 'heading_1'
"# #{rich_text_to_md(block['heading_1']['rich_text'])}"
when 'heading_2'
"## #{rich_text_to_md(block['heading_2']['rich_text'])}"
when 'heading_3'
"### #{rich_text_to_md(block['heading_3']['rich_text'])}"
when 'bulleted_list_item'
indent = ' ' * depth
"#{indent}- #{rich_text_to_md(block['bulleted_list_item']['rich_text'])}" +
children_to_md(block, depth + 1)
when 'numbered_list_item'
indent = ' ' * depth
"#{indent}1. #{rich_text_to_md(block['numbered_list_item']['rich_text'])}" +
children_to_md(block, depth + 1)
when 'to_do'
box = block['to_do']['checked'] ? '[x]' : '[ ]'
"- #{box} #{rich_text_to_md(block['to_do']['rich_text'])}"
when 'toggle'
summary = rich_text_to_md(block['toggle']['rich_text'])
details = children_to_md(block, depth + 1)
"<details>\n<summary>#{summary}</summary>\n\n#{details}\n</details>"
when 'quote'
quote = rich_text_to_md(block['quote']['rich_text'])
quote_lines = quote.lines.map { |line| "> #{line}" }.join
quote_lines + children_to_md(block, depth)
when 'code'
lang = block['code']['language'] || ''
code = block['code']['rich_text'].pluck('plain_text').join
"```#{lang}\n#{code}\n```"
when 'callout'
icon = block['callout']['icon']&.dig('emoji') || '💡'
content = rich_text_to_md(block['callout']['rich_text'])
"> [!#{icon}] #{content}" + children_to_md(block, depth + 1)
when 'divider'
'---'
when 'image'
url = block['image']['type'] == 'external' ? block['image']['external']['url'] : block['image']['file']['url']
caption = block['image']['caption']&.pluck('plain_text')&.join || 'image'
"![#{caption}](#{url})"
when 'bookmark'
url = block['bookmark']['url']
"[#{url}](#{url})"
when 'child_page'
"## #{block['child_page']['title']}" + children_to_md(block, depth + 1)
when 'table'
table_to_md(block)
else
'' # Add more block types as needed
end
end
def children_to_md(block, depth)
return '' unless block['has_children'] && block['children']
"\n" + block['children'].map { |child| block_to_markdown(child, depth) }.join("\n")
end
def rich_text_to_md(rich_text_array)
return '' unless rich_text_array
rich_text_array.map { |t| annotate_text(t) }.join
end
def annotate_text(text_obj)
text = text_obj['plain_text']
ann = text_obj['annotations'] || {}
text = "**#{text}**" if ann['bold']
text = "*#{text}*" if ann['italic']
text = "~~#{text}~~" if ann['strikethrough']
text = "<u>#{text}</u>" if ann['underline']
text = "`#{text}`" if ann['code']
text = "[#{text}](#{text_obj['href']})" if text_obj['href']
text
end
def table_to_md(block)
rows = block['children'] || []
table = rows.map do |row|
cells = row['table_row']['cells']
cells.map { |cell| rich_text_to_md(cell) }
end
return '' if table.empty?
header = table.first
sep = header.map { '---' }
([header, sep] + table[1..]).map { |row| "| #{row.join(' | ')} |" }.join("\n")
end
end
@@ -156,9 +156,14 @@
<path d="M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z"/>
</symbol>
<symbol id="icon-notion" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.104 5.91c.584.474.802.438 1.898.365l10.332-.62c.22 0 .037-.22-.036-.256l-1.716-1.24c-.329-.255-.767-.548-1.606-.475l-10.005.73c-.364.036-.437.219-.292.365zm.62 2.408v10.87c0 .585.292.803.95.767l11.354-.657c.657-.036.73-.438.73-.913V7.588c0-.474-.182-.73-.584-.693l-11.866.693c-.438.036-.584.255-.584.73m11.21.583c.072.328 0 .657-.33.694l-.547.109v8.025c-.475.256-.913.401-1.278.401c-.584 0-.73-.182-1.168-.729l-3.579-5.618v5.436l1.133.255s0 .656-.914.656l-2.519.146c-.073-.146 0-.51.256-.583l.657-.182v-7.187l-.913-.073c-.073-.329.11-.803.621-.84l2.702-.182l3.724 5.692V9.886l-.95-.109c-.072-.402.22-.693.585-.73zM4.131 3.429l10.406-.766c1.277-.11 1.606-.036 2.41.547l3.321 2.335c.548.401.731.51.731.948v12.805c0 .803-.292 1.277-1.314 1.35l-12.085.73c-.767.036-1.132-.073-1.534-.584L3.62 17.62c-.438-.584-.62-1.021-.62-1.533V4.705c0-.656.292-1.203 1.132-1.276"/>
</symbol>
<symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
<symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol>

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+4 -1
View File
@@ -168,4 +168,7 @@
enabled: true
- name: crm_integration
display_name: CRM Integration
enabled: false
enabled: false
- name: notion_integration
display_name: Notion Integration
enabled: false
+19
View File
@@ -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'
+6
View File
@@ -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
+4
View File
@@ -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.'
+10
View File
@@ -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'
@@ -0,0 +1,11 @@
class AddDocumentTypeAndExternalIdToCaptainDocuments < ActiveRecord::Migration[7.1]
def change
add_column :captain_documents, :document_type, :integer, default: 0, null: false
add_column :captain_documents, :external_id, :string
add_index :captain_documents, :document_type
add_index :captain_documents, [:assistant_id, :external_id, :document_type],
unique: true,
name: 'index_captain_documents_unique_external_id'
end
end
+6 -1
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_18_153843) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -289,9 +289,14 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "status", default: 0, null: false
t.string "crawl_type", default: "full_crawl"
t.integer "document_type", default: 0, null: false
t.string "external_id"
t.index ["account_id"], name: "index_captain_documents_on_account_id"
t.index ["assistant_id", "external_id", "document_type"], name: "index_captain_documents_unique_external_id", unique: true
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
t.index ["document_type"], name: "index_captain_documents_on_document_type"
t.index ["status"], name: "index_captain_documents_on_status"
end
@@ -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'
@@ -2,10 +2,15 @@ class Captain::Documents::CrawlJob < ApplicationJob
queue_as :low
def perform(document)
if InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
perform_firecrawl_crawl(document)
else
perform_simple_crawl(document)
case document.document_type
when 'notion'
perform_notion_crawl(document)
when 'web'
if InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
perform_firecrawl_crawl(document)
else
perform_simple_crawl(document)
end
end
end
@@ -13,6 +18,21 @@ class Captain::Documents::CrawlJob < ApplicationJob
include Captain::FirecrawlHelper
def perform_notion_crawl(document)
# external_id contains the Notion page ID for notion documents
page_id = document.external_id
crawler = Captain::Tools::NotionCrawlService.new(document.account, page_id)
page_ids = crawler.page_ids
page_ids.each do |notion_page_id|
Captain::Tools::NotionCrawlParserJob.perform_later(
assistant_id: document.assistant_id,
page_id: notion_page_id
)
end
end
def perform_simple_crawl(document)
page_links = Captain::Tools::SimplePageCrawlService.new(document.external_link).page_links
@@ -0,0 +1,47 @@
class Captain::Tools::NotionCrawlParserJob < ApplicationJob
queue_as :low
def perform(assistant_id:, page_id:)
assistant = Captain::Assistant.find(assistant_id)
account = assistant.account
if limit_exceeded?(account)
Rails.logger.info("Document limit exceeded for assistant #{assistant_id}")
return
end
processor_service = Integrations::Notion::ProcessorService.new(account: account)
page_data = processor_service.full_page(page_id)
if page_data[:error]
Rails.logger.error("Failed to fetch Notion page #{page_id}: #{page_data[:error]}")
return
end
page_title = page_data['title'] || ''
content = page_data['md'] || ''
page_url = page_data['url'] || ''
document = assistant.documents.find_or_initialize_by(
external_id: page_id,
document_type: :notion
)
document.update!(
name: page_title[0..254],
content: content[0..199_999],
external_link: page_url,
status: :available
)
rescue StandardError => e
Rails.logger.error("Failed to parse Notion page: #{page_id} - #{e.message}")
raise "Failed to parse Notion page: #{page_id} #{e.message}"
end
private
def limit_exceeded?(account)
limits = account.usage_limits[:captain][:documents]
limits[:current_available].negative? || limits[:current_available].zero?
end
end
+14 -2
View File
@@ -4,6 +4,8 @@
#
# id :bigint not null, primary key
# content :text
# crawl_type :string default("full_crawl")
# document_type :integer default("web"), not null
# external_link :string not null
# name :string
# status :integer default("in_progress"), not null
@@ -11,13 +13,16 @@
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
# external_id :string
#
# Indexes
#
# index_captain_documents_on_account_id (account_id)
# index_captain_documents_on_assistant_id (assistant_id)
# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
# index_captain_documents_on_document_type (document_type)
# index_captain_documents_on_status (status)
# index_captain_documents_unique_external_id (assistant_id,external_id,document_type) UNIQUE
#
class Captain::Document < ApplicationRecord
class LimitExceededError < StandardError; end
@@ -27,8 +32,10 @@ class Captain::Document < ApplicationRecord
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
belongs_to :account
validates :external_link, presence: true
validates :external_link, uniqueness: { scope: :assistant_id }
validates :external_link, presence: true, if: :web?
validates :external_link, uniqueness: { scope: :assistant_id }, if: :web?
validates :external_id, presence: true, if: :notion?
validates :external_id, uniqueness: { scope: [:assistant_id, :document_type] }, allow_nil: true
validates :content, length: { maximum: 200_000 }
before_validation :ensure_account_id
@@ -37,6 +44,11 @@ class Captain::Document < ApplicationRecord
available: 1
}
enum document_type: {
web: 0,
notion: 1
}
before_create :ensure_within_plan_limit
after_create_commit :enqueue_crawl_job
after_create_commit :update_document_usage
@@ -0,0 +1,46 @@
class Captain::Tools::NotionCrawlService
# The crawler will go 4 levels from the root. 5 levels in total
MAX_CRAWL_DEPTH = 4
attr_reader :account, :page_id
def initialize(account, page_id)
@account = account
@page_id = page_id
end
def page_ids
@page_ids ||= begin
result = [page_id]
child_pages = extract_child_pages(page_id)
result.concat(child_pages)
result.uniq
end
end
private
def extract_child_pages(page_id, visited = Set.new, current_depth = 0)
return [] if visited.include?(page_id)
return [] if current_depth >= MAX_CRAWL_DEPTH
visited.add(page_id)
child_pages = []
page_data = processor_service.full_page(page_id)
return child_pages if page_data[:error]
page_data['child_pages']&.each do |child_page|
child_id = child_page['id']
child_pages << child_id
child_pages.concat(extract_child_pages(child_id, visited, current_depth + 1))
end
child_pages
end
def processor_service
@processor_service ||= Integrations::Notion::ProcessorService.new(account: account)
end
end
@@ -0,0 +1,67 @@
class Integrations::Notion::ProcessorService
pattr_initialize [:account!]
def search_pages(query, sort = nil)
response = notion_client.search(query, sort)
return { error: response[:error] } if response[:error]
response['results'].map { |page| format_page(page) }
end
def page(page_id)
response = notion_client.page(page_id)
return { error: response[:error] } if response[:error]
format_page(response)
end
def full_page(page_id)
# Get page metadata
page_response = notion_client.page(page_id)
return { error: page_response[:error] } if page_response[:error]
# Get page content blocks
blocks_response = notion_client.page_blocks(page_id)
return { error: blocks_response[:error] } if blocks_response[:error]
# Use presenter to format the complete page data
NotionPagePresenter.new(page_response, blocks_response).to_hash
end
private
def format_page(page_response)
{
'id' => page_response['id'],
'icon' => page_response['icon'],
'title' => extract_page_title(page_response),
'created_time' => page_response['created_time'],
'last_edited_time' => page_response['last_edited_time']
}
end
def page_title(page_id)
page_response = notion_client.page(page_id)
return nil if page_response[:error]
extract_page_title(page_response)
end
def extract_page_title(page_response)
# Try to get title from properties (for database pages)
if page_response['properties']
title_property = page_response['properties'].values.find { |prop| prop['type'] == 'title' }
return title_property['title'].map { |t| t['plain_text'] }.join if title_property && title_property['title']&.any?
end
nil
end
def notion_hook
@notion_hook ||= account.hooks.find_by!(app_id: 'notion')
end
def notion_client
@notion_client ||= Notion.new(notion_hook.access_token)
end
end
+61
View File
@@ -0,0 +1,61 @@
class Notion
BASE_URL = 'https://api.notion.com/v1'.freeze
def initialize(access_token)
@access_token = access_token
raise ArgumentError, 'Missing Credentials' if access_token.blank?
end
def search(query = '', sort = nil)
payload = { query: query }
payload[:sort] = sort if sort.present?
response = post('search', payload)
process_response(response)
end
def page(page_id)
raise ArgumentError, 'Missing page id' if page_id.blank?
response = get("pages/#{page_id}")
process_response(response)
end
def page_blocks(page_id)
raise ArgumentError, 'Missing page id' if page_id.blank?
response = get("blocks/#{page_id}/children")
process_response(response)
end
private
def get(path)
HTTParty.get(
"#{BASE_URL}/#{path}",
headers: default_headers
)
end
def post(path, payload)
HTTParty.post(
"#{BASE_URL}/#{path}",
headers: default_headers,
body: payload.to_json
)
end
def default_headers
{
'Authorization' => "Bearer #{@access_token}",
'Notion-Version' => GlobalConfigService.load('NOTION_VERSION', '2022-06-28'),
'Content-Type' => 'application/json'
}
end
def process_response(response)
return response.parsed_response.with_indifferent_access if response.success?
{ error: response.parsed_response, error_code: response.code }
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -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
@@ -0,0 +1,62 @@
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
describe '#scope' do
it 'returns read scope for Notion API' do
expect(controller.send(:scope)).to eq('read')
end
end
end
@@ -0,0 +1,122 @@
require 'rails_helper'
RSpec.describe Notion::CallbacksController, type: :controller do
let(:account) { create(:account) }
let(:state) { account.to_sgid.to_s }
let(:oauth_code) { 'test_oauth_code' }
let(:mock_oauth_client) { instance_double(OAuth2::Client) }
let(:mock_auth_code) { instance_double(OAuth2::Strategy::AuthCode) }
let(:mock_token_response) { instance_double(OAuth2::AccessToken) }
let(:mock_response) { instance_double(Faraday::Response) }
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
before do
allow(controller).to receive(:notion_client).and_return(mock_oauth_client)
allow(mock_oauth_client).to receive(:auth_code).and_return(mock_auth_code)
allow(mock_auth_code).to receive(:get_token).and_return(mock_token_response)
allow(mock_token_response).to receive(:response).and_return(mock_response)
allow(mock_response).to receive(:parsed).and_return(notion_response_body)
end
describe 'GET #show' do
context 'when OAuth callback is successful' do
it 'creates a new integration hook' do
expect do
get :show, params: { code: oauth_code, state: state }
end.to change(Integrations::Hook, :count).by(1)
end
it 'sets correct hook attributes' do
get :show, 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 :show, 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 'redirects to integration settings page' do
get :show, params: { code: oauth_code, state: state }
expect(response).to redirect_to("#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion")
end
it 'calls the OAuth client with correct parameters' do
expect(mock_auth_code).to receive(:get_token).with(
oauth_code,
redirect_uri: "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/notion/callback"
)
get :show, params: { code: oauth_code, state: state }
end
end
context 'when hook save fails during handle_response' do
it 'raises error and uses parent exception handling' do
mock_hook = instance_double(Integrations::Hook)
allow(account.hooks).to receive(:new).and_return(mock_hook)
allow(mock_hook).to receive(:save!).and_raise(StandardError, 'Save failed')
# Parent class handles exceptions with ChatwootExceptionTracker and redirects to '/'
expect(ChatwootExceptionTracker).to receive(:new).and_call_original
get :show, params: { code: oauth_code, state: state }
expect(response).to redirect_to('/')
end
end
end
describe 'provider-specific methods' do
describe '#provider_name' do
it 'returns notion' do
expect(controller.send(:provider_name)).to eq('notion')
end
end
describe '#oauth_client' do
it 'returns notion_client' do
expect(controller.send(:oauth_client)).to eq(mock_oauth_client)
end
end
describe '#notion_redirect_uri' do
it 'returns correct redirect URI' do
allow(controller).to receive(:account).and_return(account)
expected_uri = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion"
expect(controller.send(:notion_redirect_uri)).to eq(expected_uri)
end
end
end
end
@@ -0,0 +1,253 @@
require 'rails_helper'
RSpec.describe Integrations::Notion::ProcessorService do
subject(:service) { described_class.new(account: account) }
let(:account) { create(:account) }
let(:notion_hook) { create(:integrations_hook, account: account, app_id: 'notion', access_token: 'notion_token') }
let(:notion_client) { instance_double(Notion) }
before do
notion_hook
allow(Notion).to receive(:new).with('notion_token').and_return(notion_client)
end
describe '#search_pages' do
let(:query) { 'test query' }
let(:sort) { { direction: 'ascending', timestamp: 'last_edited_time' } }
context 'when search is successful' do
let(:search_response) do
{
'results' => [
{
'id' => '123',
'icon' => { 'emoji' => '📄' },
'created_time' => '2023-01-01T10:00:00.000Z',
'last_edited_time' => '2023-01-02T15:30:00.000Z',
'properties' => {
'title' => {
'type' => 'title',
'title' => [{ 'plain_text' => 'Test Page' }]
}
}
}
]
}
end
before do
allow(notion_client).to receive(:search).with(query, sort).and_return(search_response)
end
it 'returns formatted pages with selected fields' do
result = service.search_pages(query, sort)
expect(result).to eq([
{
'id' => '123',
'icon' => { 'emoji' => '📄' },
'title' => 'Test Page',
'created_time' => '2023-01-01T10:00:00.000Z',
'last_edited_time' => '2023-01-02T15:30:00.000Z'
}
])
end
end
context 'when search fails' do
let(:error_response) { { error: 'API error', error_code: 400 } }
before do
allow(notion_client).to receive(:search).with(query, sort).and_return(error_response)
end
it 'returns error' do
result = service.search_pages(query, sort)
expect(result).to eq({ error: 'API error' })
end
end
end
describe '#page' do
let(:page_id) { 'page-123' }
context 'when page retrieval is successful' do
let(:page_response) do
{
'id' => page_id,
'icon' => { 'emoji' => '📄' },
'created_time' => '2023-01-01T10:00:00.000Z',
'last_edited_time' => '2023-01-02T15:30:00.000Z',
'properties' => {
'title' => {
'type' => 'title',
'title' => [{ 'plain_text' => 'Test Page' }]
}
}
}
end
before do
allow(notion_client).to receive(:page).with(page_id).and_return(page_response)
end
it 'returns formatted page data' do
result = service.page(page_id)
expect(result).to eq({
'id' => page_id,
'icon' => { 'emoji' => '📄' },
'title' => 'Test Page',
'created_time' => '2023-01-01T10:00:00.000Z',
'last_edited_time' => '2023-01-02T15:30:00.000Z'
})
end
end
context 'when page retrieval fails' do
let(:error_response) { { error: 'Page not found', error_code: 404 } }
before do
allow(notion_client).to receive(:page).with(page_id).and_return(error_response)
end
it 'returns error' do
result = service.page(page_id)
expect(result).to eq({ error: 'Page not found' })
end
end
end
describe '#full_page' do
let(:page_id) { 'page-123' }
context 'when both page and blocks retrieval are successful' do
let(:page_response) do
{
'id' => page_id,
'icon' => { 'emoji' => '📄' },
'created_time' => '2023-01-01T10:00:00.000Z',
'last_edited_time' => '2023-01-02T15:30:00.000Z',
'properties' => {
'title' => {
'type' => 'title',
'title' => [{ 'plain_text' => 'Test Page' }]
}
}
}
end
let(:blocks_response) do
{
'results' => [
{
'type' => 'paragraph',
'paragraph' => {
'rich_text' => [{ 'plain_text' => 'Hello world' }]
}
},
{
'type' => 'child_page',
'id' => 'child-page-1',
'child_page' => {
'title' => 'Child Page 1'
}
}
]
}
end
let(:presenter) { instance_double(NotionPagePresenter) }
let(:presenter_result) do
{
'id' => page_id,
'icon' => { 'emoji' => '📄' },
'title' => 'Test Page',
'created_time' => '2023-01-01T10:00:00.000Z',
'last_edited_time' => '2023-01-02T15:30:00.000Z',
'md' => "# Test Page\n\nHello world",
'child_pages' => [
{
'id' => 'child-page-1',
'title' => 'Child Page 1'
}
]
}
end
before do
allow(notion_client).to receive(:page).with(page_id).and_return(page_response)
allow(notion_client).to receive(:page_blocks).with(page_id).and_return(blocks_response)
allow(NotionPagePresenter).to receive(:new).with(page_response, blocks_response).and_return(presenter)
allow(presenter).to receive(:to_hash).and_return(presenter_result)
end
it 'returns complete page data with markdown and child pages' do
result = service.full_page(page_id)
expect(result).to eq(presenter_result)
end
it 'creates presenter with page and blocks responses' do
service.full_page(page_id)
expect(NotionPagePresenter).to have_received(:new).with(page_response, blocks_response)
end
it 'calls to_hash on the presenter' do
service.full_page(page_id)
expect(presenter).to have_received(:to_hash)
end
end
context 'when page retrieval fails' do
let(:error_response) { { error: 'Page not found', error_code: 404 } }
before do
allow(notion_client).to receive(:page).with(page_id).and_return(error_response)
allow(notion_client).to receive(:page_blocks)
end
it 'returns error without calling page_blocks' do
result = service.full_page(page_id)
expect(result).to eq({ error: 'Page not found' })
expect(notion_client).not_to have_received(:page_blocks)
end
end
context 'when page blocks retrieval fails' do
let(:page_response) { { 'id' => page_id, 'title' => 'Test Page' } }
let(:error_response) { { error: 'Blocks not found', error_code: 404 } }
before do
allow(notion_client).to receive(:page).with(page_id).and_return(page_response)
allow(notion_client).to receive(:page_blocks).with(page_id).and_return(error_response)
allow(NotionPagePresenter).to receive(:new)
end
it 'returns error without calling presenter' do
result = service.full_page(page_id)
expect(result).to eq({ error: 'Blocks not found' })
expect(NotionPagePresenter).not_to have_received(:new)
end
end
end
describe 'private methods' do
describe '#notion_hook' do
it 'finds the notion hook for the account' do
expect(service.send(:notion_hook)).to eq(notion_hook)
end
it 'raises error if hook not found' do
notion_hook.destroy!
expect { service.send(:notion_hook) }.to raise_error(ActiveRecord::RecordNotFound)
end
end
describe '#notion_client' do
it 'creates a Notion client with the hook access token' do
client = service.send(:notion_client)
expect(Notion).to have_received(:new).with('notion_token')
expect(client).to eq(notion_client)
end
end
end
end
+134
View File
@@ -0,0 +1,134 @@
require 'rails_helper'
RSpec.describe Notion do
let(:access_token) { 'notion_access_token' }
let(:notion_client) { described_class.new(access_token) }
describe '#initialize' do
it 'initializes with access token' do
expect(notion_client.instance_variable_get(:@access_token)).to eq(access_token)
end
it 'raises error when access token is blank' do
expect { described_class.new('') }.to raise_error(ArgumentError, 'Missing Credentials')
end
end
describe '#search' do
let(:query) { 'test query' }
let(:sort) { { direction: 'ascending', timestamp: 'last_edited_time' } }
let(:response_body) { { 'results' => [{ 'id' => '123', 'object' => 'page' }] } }
let(:response) { instance_double(HTTParty::Response, success?: true, parsed_response: response_body) }
before do
allow(HTTParty).to receive(:post).and_return(response)
end
it 'makes a POST request to search endpoint' do
notion_client.search(query)
expect(HTTParty).to have_received(:post).with(
'https://api.notion.com/v1/search',
headers: {
'Authorization' => "Bearer #{access_token}",
'Notion-Version' => '2022-06-28',
'Content-Type' => 'application/json'
},
body: { query: query }.to_json
)
end
it 'includes sort parameters when provided' do
notion_client.search(query, sort)
expect(HTTParty).to have_received(:post).with(
'https://api.notion.com/v1/search',
headers: anything,
body: { query: query, sort: sort }.to_json
)
end
it 'returns parsed response on success' do
result = notion_client.search(query)
expect(result).to eq(response_body.with_indifferent_access)
end
context 'when request fails' do
let(:error_response) { { 'object' => 'error', 'status' => 400 } }
let(:failed_response) { instance_double(HTTParty::Response, success?: false, parsed_response: error_response, code: 400) }
before do
allow(HTTParty).to receive(:post).and_return(failed_response)
end
it 'returns error response' do
result = notion_client.search(query)
expect(result).to eq({ error: error_response, error_code: 400 })
end
end
end
describe '#page' do
let(:page_id) { 'page-123' }
let(:response_body) { { 'id' => page_id, 'object' => 'page' } }
let(:response) { instance_double(HTTParty::Response, success?: true, parsed_response: response_body) }
before do
allow(HTTParty).to receive(:get).and_return(response)
end
it 'makes a GET request to pages endpoint' do
notion_client.page(page_id)
expect(HTTParty).to have_received(:get).with(
"https://api.notion.com/v1/pages/#{page_id}",
headers: {
'Authorization' => "Bearer #{access_token}",
'Notion-Version' => '2022-06-28',
'Content-Type' => 'application/json'
}
)
end
it 'returns parsed response on success' do
result = notion_client.page(page_id)
expect(result).to eq(response_body.with_indifferent_access)
end
it 'raises error when page_id is blank' do
expect { notion_client.page('') }.to raise_error(ArgumentError, 'Missing page id')
end
end
describe '#page_blocks' do
let(:page_id) { 'page-123' }
let(:response_body) { { 'results' => [{ 'id' => 'block-1', 'type' => 'paragraph' }] } }
let(:response) { instance_double(HTTParty::Response, success?: true, parsed_response: response_body) }
before do
allow(HTTParty).to receive(:get).and_return(response)
end
it 'makes a GET request to blocks endpoint' do
notion_client.page_blocks(page_id)
expect(HTTParty).to have_received(:get).with(
"https://api.notion.com/v1/blocks/#{page_id}/children",
headers: {
'Authorization' => "Bearer #{access_token}",
'Notion-Version' => '2022-06-28',
'Content-Type' => 'application/json'
}
)
end
it 'returns parsed response on success' do
result = notion_client.page_blocks(page_id)
expect(result).to eq(response_body.with_indifferent_access)
end
it 'raises error when page_id is blank' do
expect { notion_client.page_blocks('') }.to raise_error(ArgumentError, 'Missing page id')
end
end
end
+40
View File
@@ -78,6 +78,46 @@ RSpec.describe Integrations::Hook do
end
end
describe 'app type methods' do
let(:account) { create(:account) }
describe '#slack?' do
it 'returns true for slack integration' do
hook = create(:integrations_hook, account: account, app_id: 'slack')
expect(hook.slack?).to be true
end
it 'returns false for non-slack integrations' do
hook = create(:integrations_hook, account: account, app_id: 'notion')
expect(hook.slack?).to be false
end
end
describe '#dialogflow?' do
it 'returns true for dialogflow integration' do
hook = create(:integrations_hook, account: account, app_id: 'dialogflow')
expect(hook.dialogflow?).to be true
end
it 'returns false for non-dialogflow integrations' do
hook = create(:integrations_hook, account: account, app_id: 'notion')
expect(hook.dialogflow?).to be false
end
end
describe '#notion?' do
it 'returns true for notion integration' do
hook = create(:integrations_hook, account: account, app_id: 'notion')
expect(hook.notion?).to be true
end
it 'returns false for non-notion integrations' do
hook = create(:integrations_hook, account: account, app_id: 'slack')
expect(hook.notion?).to be false
end
end
end
describe '#crm_integration?' do
let(:account) { create(:account) }
@@ -0,0 +1,204 @@
require 'rails_helper'
RSpec.describe NotionPagePresenter do
subject(:presenter) { described_class.new(page_response, blocks_response) }
let(:page_response) do
{
'id' => 'page-123',
'icon' => { 'emoji' => '📄' },
'created_time' => '2023-01-01T10:00:00.000Z',
'last_edited_time' => '2023-01-02T15:30:00.000Z',
'properties' => {
'title' => {
'type' => 'title',
'title' => [
{ 'plain_text' => 'Test Page Title' }
]
}
}
}
end
let(:blocks_response) do
{
'results' => [
{
'type' => 'paragraph',
'paragraph' => {
'rich_text' => [
{ 'plain_text' => 'This is a paragraph.' }
]
}
},
{
'type' => 'child_page',
'id' => 'child-page-1',
'child_page' => {
'title' => 'Child Page 1'
}
}
]
}
end
let(:markdown_converter) { instance_double(NotionToMarkdown) }
before do
allow(NotionToMarkdown).to receive(:new).and_return(markdown_converter)
allow(markdown_converter).to receive(:convert).and_return('This is a paragraph.')
end
describe '#to_hash' do
it 'returns a properly formatted hash with all required fields' do
result = presenter.to_hash
expect(result).to include(
'id' => 'page-123',
'icon' => { 'emoji' => '📄' },
'title' => 'Test Page Title',
'created_time' => '2023-01-01T10:00:00.000Z',
'last_edited_time' => '2023-01-02T15:30:00.000Z'
)
end
it 'includes markdown content with title as H1' do
result = presenter.to_hash
expect(result['md']).to eq("# Test Page Title\n\nThis is a paragraph.")
end
it 'includes child pages with id and title' do
result = presenter.to_hash
expect(result['child_pages']).to eq([
{
'id' => 'child-page-1',
'title' => 'Child Page 1'
}
])
end
it 'calls NotionToMarkdown converter with blocks' do
presenter.to_hash
expect(markdown_converter).to have_received(:convert).with(blocks_response['results'])
end
context 'when page has no title in properties' do
let(:page_response) do
{
'id' => 'page-123',
'icon' => nil,
'created_time' => '2023-01-01T10:00:00.000Z',
'last_edited_time' => '2023-01-02T15:30:00.000Z',
'properties' => {}
}
end
it 'returns nil for title and no H1 in markdown' do
result = presenter.to_hash
expect(result['title']).to be_nil
expect(result['md']).to eq('This is a paragraph.')
end
end
context 'when page has no properties' do
let(:page_response) do
{
'id' => 'page-123',
'icon' => nil,
'created_time' => '2023-01-01T10:00:00.000Z',
'last_edited_time' => '2023-01-02T15:30:00.000Z'
}
end
it 'returns nil for title' do
result = presenter.to_hash
expect(result['title']).to be_nil
end
end
context 'with nested child pages' do
let(:blocks_response) do
{
'results' => [
{
'type' => 'toggle',
'has_children' => true,
'children' => [
{
'type' => 'child_page',
'id' => 'nested-child-1',
'child_page' => {
'title' => 'Nested Child Page'
}
}
]
},
{
'type' => 'child_page',
'id' => 'top-level-child',
'child_page' => {
'title' => 'Top Level Child'
}
}
]
}
end
it 'extracts all child pages including nested ones' do
result = presenter.to_hash
expect(result['child_pages']).to contain_exactly(
{
'id' => 'nested-child-1',
'title' => 'Nested Child Page'
},
{
'id' => 'top-level-child',
'title' => 'Top Level Child'
}
)
end
end
context 'when blocks response has no child pages' do
let(:blocks_response) do
{
'results' => [
{
'type' => 'paragraph',
'paragraph' => {
'rich_text' => [
{ 'plain_text' => 'Just a paragraph.' }
]
}
}
]
}
end
it 'returns empty child_pages array' do
result = presenter.to_hash
expect(result['child_pages']).to eq([])
end
end
context 'when blocks response is empty' do
let(:blocks_response) { { 'results' => [] } }
it 'handles empty blocks gracefully' do
allow(markdown_converter).to receive(:convert).with([]).and_return('')
result = presenter.to_hash
expect(result['md']).to eq("# Test Page Title\n\n")
expect(result['child_pages']).to eq([])
end
end
end
end
+414
View File
@@ -0,0 +1,414 @@
require 'rails_helper'
RSpec.describe NotionToMarkdown do
subject(:converter) { described_class.new }
describe '#convert' do
context 'with paragraph blocks' do
let(:blocks) do
[
{
'type' => 'paragraph',
'paragraph' => {
'rich_text' => [
{ 'plain_text' => 'This is a simple paragraph.' }
]
}
}
]
end
it 'converts paragraph to markdown' do
result = converter.convert(blocks)
expect(result).to eq('This is a simple paragraph.')
end
end
context 'with heading blocks' do
let(:blocks) do
[
{
'type' => 'heading_1',
'heading_1' => {
'rich_text' => [{ 'plain_text' => 'Main Title' }]
}
},
{
'type' => 'heading_2',
'heading_2' => {
'rich_text' => [{ 'plain_text' => 'Subtitle' }]
}
},
{
'type' => 'heading_3',
'heading_3' => {
'rich_text' => [{ 'plain_text' => 'Sub-subtitle' }]
}
}
]
end
it 'converts headings to markdown' do
result = converter.convert(blocks)
expected = "# Main Title\n\n## Subtitle\n\n### Sub-subtitle"
expect(result).to eq(expected)
end
end
context 'with list blocks' do
let(:blocks) do
[
{
'type' => 'bulleted_list_item',
'bulleted_list_item' => {
'rich_text' => [{ 'plain_text' => 'First bullet point' }]
}
},
{
'type' => 'numbered_list_item',
'numbered_list_item' => {
'rich_text' => [{ 'plain_text' => 'First numbered item' }]
}
}
]
end
it 'converts lists to markdown' do
result = converter.convert(blocks)
expected = "- First bullet point\n\n1. First numbered item"
expect(result).to eq(expected)
end
end
context 'with to-do blocks' do
let(:blocks) do
[
{
'type' => 'to_do',
'to_do' => {
'checked' => false,
'rich_text' => [{ 'plain_text' => 'Incomplete task' }]
}
},
{
'type' => 'to_do',
'to_do' => {
'checked' => true,
'rich_text' => [{ 'plain_text' => 'Completed task' }]
}
}
]
end
it 'converts to-dos to markdown' do
result = converter.convert(blocks)
expected = "- [ ] Incomplete task\n\n- [x] Completed task"
expect(result).to eq(expected)
end
end
context 'with rich text formatting' do
let(:blocks) do
[
{
'type' => 'paragraph',
'paragraph' => {
'rich_text' => [
{
'plain_text' => 'This is bold',
'annotations' => { 'bold' => true }
},
{ 'plain_text' => ' and ' },
{
'plain_text' => 'this is italic',
'annotations' => { 'italic' => true }
},
{ 'plain_text' => ' and ' },
{
'plain_text' => 'this is code',
'annotations' => { 'code' => true }
}
]
}
}
]
end
it 'applies rich text formatting' do
result = converter.convert(blocks)
expected = '**This is bold** and *this is italic* and `this is code`'
expect(result).to eq(expected)
end
end
context 'with links' do
let(:blocks) do
[
{
'type' => 'paragraph',
'paragraph' => {
'rich_text' => [
{
'plain_text' => 'Visit our website',
'href' => 'https://example.com'
}
]
}
}
]
end
it 'converts links to markdown' do
result = converter.convert(blocks)
expected = '[Visit our website](https://example.com)'
expect(result).to eq(expected)
end
end
context 'with code blocks' do
let(:blocks) do
[
{
'type' => 'code',
'code' => {
'language' => 'javascript',
'rich_text' => [
{ 'plain_text' => "console.log('Hello, world!');" }
]
}
}
]
end
it 'converts code blocks to markdown' do
result = converter.convert(blocks)
expected = "```javascript\nconsole.log('Hello, world!');\n```"
expect(result).to eq(expected)
end
end
context 'with quote blocks' do
let(:blocks) do
[
{
'type' => 'quote',
'quote' => {
'rich_text' => [
{ 'plain_text' => 'This is a quote' }
]
}
}
]
end
it 'converts quotes to markdown' do
result = converter.convert(blocks)
expected = '> This is a quote'
expect(result).to eq(expected)
end
end
context 'with callout blocks' do
let(:blocks) do
[
{
'type' => 'callout',
'callout' => {
'icon' => { 'emoji' => '⚠️' },
'rich_text' => [
{ 'plain_text' => 'This is a warning' }
]
}
}
]
end
it 'converts callouts to markdown' do
result = converter.convert(blocks)
expected = '> [!⚠️] This is a warning'
expect(result).to eq(expected)
end
end
context 'with divider blocks' do
let(:blocks) do
[
{
'type' => 'divider',
'divider' => {}
}
]
end
it 'converts dividers to markdown' do
result = converter.convert(blocks)
expected = '---'
expect(result).to eq(expected)
end
end
context 'with image blocks' do
let(:blocks) do
[
{
'type' => 'image',
'image' => {
'type' => 'external',
'external' => {
'url' => 'https://example.com/image.jpg'
},
'caption' => [
{ 'plain_text' => 'Example image' }
]
}
}
]
end
it 'converts images to markdown' do
result = converter.convert(blocks)
expected = '![Example image](https://example.com/image.jpg)'
expect(result).to eq(expected)
end
end
context 'with bookmark blocks' do
let(:blocks) do
[
{
'type' => 'bookmark',
'bookmark' => {
'url' => 'https://example.com'
}
}
]
end
it 'converts bookmarks to markdown' do
result = converter.convert(blocks)
expected = '[https://example.com](https://example.com)'
expect(result).to eq(expected)
end
end
context 'with table blocks' do
let(:blocks) do
[
{
'type' => 'table',
'table' => {},
'children' => [
{
'type' => 'table_row',
'table_row' => {
'cells' => [
[{ 'plain_text' => 'Name' }],
[{ 'plain_text' => 'Age' }]
]
}
},
{
'type' => 'table_row',
'table_row' => {
'cells' => [
[{ 'plain_text' => 'John' }],
[{ 'plain_text' => '30' }]
]
}
}
]
}
]
end
it 'converts tables to markdown' do
result = converter.convert(blocks)
expected = "| Name | Age |\n| --- | --- |\n| John | 30 |"
expect(result).to eq(expected)
end
end
context 'with nested list items' do
let(:blocks) do
[
{
'type' => 'bulleted_list_item',
'bulleted_list_item' => {
'rich_text' => [{ 'plain_text' => 'Parent item' }]
},
'has_children' => true,
'children' => [
{
'type' => 'bulleted_list_item',
'bulleted_list_item' => {
'rich_text' => [{ 'plain_text' => 'Child item' }]
}
}
]
}
]
end
it 'handles nested lists with proper indentation' do
result = converter.convert(blocks)
expected = "- Parent item\n - Child item"
expect(result).to eq(expected)
end
end
context 'with toggle blocks' do
let(:blocks) do
[
{
'type' => 'toggle',
'toggle' => {
'rich_text' => [{ 'plain_text' => 'Click to expand' }]
},
'has_children' => true,
'children' => [
{
'type' => 'paragraph',
'paragraph' => {
'rich_text' => [{ 'plain_text' => 'Hidden content' }]
}
}
]
}
]
end
it 'converts toggles to HTML details' do
result = converter.convert(blocks)
expected = "<details>\n<summary>Click to expand</summary>\n\n\nHidden content\n</details>"
expect(result).to eq(expected)
end
end
context 'with unknown block types' do
let(:blocks) do
[
{
'type' => 'unknown_block_type',
'unknown_block_type' => {
'content' => 'Some content'
}
}
]
end
it 'ignores unknown block types' do
result = converter.convert(blocks)
expect(result).to eq('')
end
end
context 'with empty blocks array' do
let(:blocks) { [] }
it 'returns empty string' do
result = converter.convert(blocks)
expect(result).to eq('')
end
end
end
end