diff --git a/packages/backend/src/apps/notion/assets/favicon.svg b/packages/backend/src/apps/notion/assets/favicon.svg new file mode 100644 index 00000000..ebcbe816 --- /dev/null +++ b/packages/backend/src/apps/notion/assets/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/backend/src/apps/notion/auth/generate-auth-url.ts b/packages/backend/src/apps/notion/auth/generate-auth-url.ts new file mode 100644 index 00000000..e34c1ead --- /dev/null +++ b/packages/backend/src/apps/notion/auth/generate-auth-url.ts @@ -0,0 +1,21 @@ +import { IField, IGlobalVariable } from '@automatisch/types'; +import { URL, URLSearchParams } from 'url'; + +export default async function generateAuthUrl($: IGlobalVariable) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field: IField) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value as string; + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId as string, + redirect_uri: redirectUri, + response_type: 'code', + owner: 'user', + }); + + const url = new URL(`/v1/oauth/authorize?${searchParams}`, $.app.apiBaseUrl).toString(); + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/notion/auth/index.ts b/packages/backend/src/apps/notion/auth/index.ts new file mode 100644 index 00000000..8c9d1aa6 --- /dev/null +++ b/packages/backend/src/apps/notion/auth/index.ts @@ -0,0 +1,49 @@ +import generateAuthUrl from './generate-auth-url'; +import verifyCredentials from './verify-credentials'; +import isStillVerified from './is-still-verified'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string' as const, + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/notion/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Notion OAuth, enter the URL above.', + docUrl: 'https://automatisch.io/docs/notion#oauth-redirect-url', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string' as const, + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/notion#client-id', + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string' as const, + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/notion#client-secret', + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/notion/auth/is-still-verified.ts b/packages/backend/src/apps/notion/auth/is-still-verified.ts new file mode 100644 index 00000000..befb7694 --- /dev/null +++ b/packages/backend/src/apps/notion/auth/is-still-verified.ts @@ -0,0 +1,9 @@ +import { IGlobalVariable } from '@automatisch/types'; +import getCurrentUser from '../common/get-current-user'; + +const isStillVerified = async ($: IGlobalVariable) => { + const user = await getCurrentUser($); + return !!user.id; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/notion/auth/verify-credentials.ts b/packages/backend/src/apps/notion/auth/verify-credentials.ts new file mode 100644 index 00000000..f3104eca --- /dev/null +++ b/packages/backend/src/apps/notion/auth/verify-credentials.ts @@ -0,0 +1,53 @@ +import { IGlobalVariable, IField } from '@automatisch/types'; +import getCurrentUser from '../common/get-current-user'; + +const verifyCredentials = async ($: IGlobalVariable) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field: IField) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value as string; + const response = await $.http.post( + `${$.app.apiBaseUrl}/v1/oauth/token`, + { + redirect_uri: redirectUri, + code: $.auth.data.code, + grant_type: 'authorization_code', + }, + { + headers: { + Authorization: `Basic ${Buffer.from( + $.auth.data.clientId + ':' + $.auth.data.clientSecret + ).toString('base64')}`, + }, + additionalProperties: { + skipAddingAuthHeader: true + } + } + ); + + const data = response.data; + + $.auth.data.accessToken = data.access_token; + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + accessToken: data.access_token, + botId: data.bot_id, + duplicatedTemplateId: data.duplicated_template_id, + owner: data.owner, + tokenType: data.token_type, + workspaceIcon: data.workspace_icon, + workspaceId: data.workspace_id, + workspaceName: data.workspace_name, + screenName: data.workspace_name, + }); + + const currentUser = await getCurrentUser($); + + await $.auth.set({ + screenName: `${currentUser.name} @ ${data.workspace_name}`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/notion/common/add-auth-header.ts b/packages/backend/src/apps/notion/common/add-auth-header.ts new file mode 100644 index 00000000..d16f394f --- /dev/null +++ b/packages/backend/src/apps/notion/common/add-auth-header.ts @@ -0,0 +1,14 @@ +import { TBeforeRequest } from '@automatisch/types'; + +const addAuthHeader: TBeforeRequest = ($, requestConfig) => { + if (requestConfig.additionalProperties?.skipAddingAuthHeader) return requestConfig; + + if ($.auth.data?.accessToken) { + const authorizationHeader = `Bearer ${$.auth.data.accessToken}`; + requestConfig.headers.Authorization = authorizationHeader; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/notion/common/add-notion-version-header.ts b/packages/backend/src/apps/notion/common/add-notion-version-header.ts new file mode 100644 index 00000000..0c6197a4 --- /dev/null +++ b/packages/backend/src/apps/notion/common/add-notion-version-header.ts @@ -0,0 +1,9 @@ +import { TBeforeRequest } from '@automatisch/types'; + +const addNotionVersionHeader: TBeforeRequest = ($, requestConfig) => { + requestConfig.headers['Notion-Version'] = '2022-06-28'; + + return requestConfig; +}; + +export default addNotionVersionHeader; diff --git a/packages/backend/src/apps/notion/common/get-current-user.ts b/packages/backend/src/apps/notion/common/get-current-user.ts new file mode 100644 index 00000000..1110d23b --- /dev/null +++ b/packages/backend/src/apps/notion/common/get-current-user.ts @@ -0,0 +1,17 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; + +type Owner = { + user: { + id: string + } +} + +const getCurrentUser = async ($: IGlobalVariable): Promise => { + const userId = ($.auth.data.owner as Owner).user.id; + const response = await $.http.get(`/v1/users/${userId}`); + + const currentUser = response.data; + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/notion/dynamic-data/index.ts b/packages/backend/src/apps/notion/dynamic-data/index.ts new file mode 100644 index 00000000..70002de4 --- /dev/null +++ b/packages/backend/src/apps/notion/dynamic-data/index.ts @@ -0,0 +1,3 @@ +import listDatabases from './list-databases'; + +export default [listDatabases]; diff --git a/packages/backend/src/apps/notion/dynamic-data/list-databases/index.ts b/packages/backend/src/apps/notion/dynamic-data/list-databases/index.ts new file mode 100644 index 00000000..ca16aaa2 --- /dev/null +++ b/packages/backend/src/apps/notion/dynamic-data/list-databases/index.ts @@ -0,0 +1,60 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; + +type Database = { + id: string; + name: string; + title: [ + { + plain_text: string; + } + ]; +} + +type ResponseData = { + results: Database[]; + next_cursor?: string; +} + +type Payload = { + filter: { + value: 'database'; + property: 'object'; + }; + start_cursor?: string; +}; + +export default { + name: 'List databases', + key: 'listDatabases', + + async run($: IGlobalVariable) { + const databases: { + data: IJSONObject[]; + error: IJSONObject | null; + } = { + data: [], + error: null, + }; + const payload: Payload = { + filter: { + value: 'database', + property: 'object' + }, + }; + + do { + const response = await $.http.post('/v1/search', payload); + + payload.start_cursor = response.data.next_cursor; + + for (const database of response.data.results) { + databases.data.push({ + value: database.id as string, + name: database.title[0].plain_text as string, + }); + } + } while (payload.start_cursor); + + return databases; + }, +}; diff --git a/packages/backend/src/apps/notion/index.d.ts b/packages/backend/src/apps/notion/index.d.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/backend/src/apps/notion/index.ts b/packages/backend/src/apps/notion/index.ts new file mode 100644 index 00000000..b26b178c --- /dev/null +++ b/packages/backend/src/apps/notion/index.ts @@ -0,0 +1,24 @@ +import defineApp from '../../helpers/define-app'; +import addAuthHeader from './common/add-auth-header'; +import addNotionVersionHeader from './common/add-notion-version-header'; +import auth from './auth'; +import triggers from './triggers'; +import dynamicData from './dynamic-data'; + +export default defineApp({ + name: 'Notion', + key: 'notion', + baseUrl: 'https://notion.com', + apiBaseUrl: 'https://api.notion.com', + iconUrl: '{BASE_URL}/apps/notion/assets/favicon.svg', + authDocUrl: 'https://automatisch.io/docs/apps/notion/connection', + primaryColor: '000000', + supportsConnections: true, + beforeRequest: [ + addAuthHeader, + addNotionVersionHeader, + ], + auth, + triggers, + dynamicData, +}); diff --git a/packages/backend/src/apps/notion/triggers/index.ts b/packages/backend/src/apps/notion/triggers/index.ts new file mode 100644 index 00000000..260f1756 --- /dev/null +++ b/packages/backend/src/apps/notion/triggers/index.ts @@ -0,0 +1,3 @@ +import newDatabaseItems from './new-database-items'; + +export default [newDatabaseItems]; diff --git a/packages/backend/src/apps/notion/triggers/new-database-items/index.ts b/packages/backend/src/apps/notion/triggers/new-database-items/index.ts new file mode 100644 index 00000000..989c97f7 --- /dev/null +++ b/packages/backend/src/apps/notion/triggers/new-database-items/index.ts @@ -0,0 +1,32 @@ +import defineTrigger from '../../../../helpers/define-trigger'; +import newDatabaseItems from './new-database-items'; + +export default defineTrigger({ + name: 'New database items', + key: 'newDatabaseItems', + pollInterval: 15, + description: 'Triggers when a new database item is created', + arguments: [ + { + label: 'Database', + key: 'databaseId', + type: 'dropdown' as const, + required: false, + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDatabases', + }, + ], + }, + }, + ], + + async run($) { + await newDatabaseItems($); + }, +}); diff --git a/packages/backend/src/apps/notion/triggers/new-database-items/new-database-items.ts b/packages/backend/src/apps/notion/triggers/new-database-items/new-database-items.ts new file mode 100644 index 00000000..90b619c8 --- /dev/null +++ b/packages/backend/src/apps/notion/triggers/new-database-items/new-database-items.ts @@ -0,0 +1,50 @@ +import { IGlobalVariable } from '@automatisch/types'; + +type DatabaseItem = { + id: string; +} + +type ResponseData = { + results: DatabaseItem[]; + next_cursor?: string; +} + +type Payload = { + sorts: [ + { + timestamp: 'created_time' | 'last_edited_time'; + direction: 'ascending' | 'descending'; + } + ]; + start_cursor?: string; +}; + +const newDatabaseItems = async ($: IGlobalVariable) => { + const payload: Payload = { + sorts: [ + { + timestamp: 'created_time', + direction: 'descending' + } + ], + }; + + const databaseId = $.step.parameters.databaseId as string; + const path = `/v1/databases/${databaseId}/query`; + do { + const response = await $.http.post(path, payload); + + payload.start_cursor = response.data.next_cursor; + + for (const databaseItem of response.data.results) { + $.pushTriggerItem({ + raw: databaseItem, + meta: { + internalId: databaseItem.id, + } + }) + } + } while (payload.start_cursor); +}; + +export default newDatabaseItems; diff --git a/packages/backend/src/graphql/queries/get-dynamic-data.ts b/packages/backend/src/graphql/queries/get-dynamic-data.ts index 5b713049..d5a85baf 100644 --- a/packages/backend/src/graphql/queries/get-dynamic-data.ts +++ b/packages/backend/src/graphql/queries/get-dynamic-data.ts @@ -44,9 +44,12 @@ const getDynamicData = async ( $.step.parameters[parameterKey] = parameterValue; } - const priorExecutionSteps = await ExecutionStep.query().where({ - execution_id: (await flow.$relatedQuery('lastExecution')).id, - }); + const lastExecution = await flow.$relatedQuery('lastExecution'); + const lastExecutionId = lastExecution?.id; + + const priorExecutionSteps = lastExecutionId ? await ExecutionStep.query().where({ + execution_id: lastExecutionId, + }) : []; // compute variables in parameters const computedParameters = computeParameters($.step.parameters, priorExecutionSteps); diff --git a/packages/docs/pages/.vitepress/config.js b/packages/docs/pages/.vitepress/config.js index 905b7913..59965de5 100644 --- a/packages/docs/pages/.vitepress/config.js +++ b/packages/docs/pages/.vitepress/config.js @@ -142,6 +142,15 @@ export default defineConfig({ { text: 'Connection', link: '/apps/http-request/connection' }, ], }, + { + text: 'Notion', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/notion/triggers' }, + { text: 'Connection', link: '/apps/notion/connection' }, + ], + }, { text: 'Ntfy', collapsible: true, diff --git a/packages/docs/pages/apps/notion/connection.md b/packages/docs/pages/apps/notion/connection.md new file mode 100644 index 00000000..363b0c0a --- /dev/null +++ b/packages/docs/pages/apps/notion/connection.md @@ -0,0 +1,22 @@ +# Notion + +:::info +This page explains the steps you need to follow to set up the Notion +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://www.notion.so/my-integrations) to **create an + integration** on Notion API. +1. Fill out the Name field. +1. Click on the **Submit** button. +1. Go to the **Capabilities** page via the sidebar. +1. Select the **Read user information without email addresses** option under the **User Capabilities** section and then save the changes. +1. Go to the **Distribution** page via the sidebar. +1. Make the integration public by enabling the checkbox. +1. Fill out the necessary fields under the **Organization Information** section. +1. Copy **OAuth Redirect URL** from Automatisch and paste it to the **Redirect URIs** field. +1. Click on the **Submit** button. +1. Accept making the integration public by clicking on the **Continue** button in the dialog. +1. Copy **OAuth client ID** and **OAuth client secret** values and paste them into Automatisch as **Client ID** and **Client Secret**, respectively. +1. Click **Submit** button on Automatisch. +1. Now, you can start using the Notion connection with Automatisch. diff --git a/packages/docs/pages/apps/notion/triggers.md b/packages/docs/pages/apps/notion/triggers.md new file mode 100644 index 00000000..91e33a0b --- /dev/null +++ b/packages/docs/pages/apps/notion/triggers.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/notion.svg +items: + - name: New database items + desc: Triggers when a new database item is created. +--- + + + + diff --git a/packages/docs/pages/guide/available-apps.md b/packages/docs/pages/guide/available-apps.md index 6d6a48ff..2fd0e483 100644 --- a/packages/docs/pages/guide/available-apps.md +++ b/packages/docs/pages/guide/available-apps.md @@ -18,6 +18,7 @@ Following integrations are currently supported by Automatisch. - [Google Forms](/apps/google-forms/triggers) - [Google Sheets](/apps/google-sheets/triggers) - [HTTP Request](/apps/http-request/actions) +- [Notion](/apps/notion/triggers) - [Ntfy](/apps/ntfy/actions) - [OpenAI](/apps/openai/actions) - [PostgreSQL](/apps/postgresql/actions) diff --git a/packages/docs/pages/public/favicons/notion.svg b/packages/docs/pages/public/favicons/notion.svg new file mode 100644 index 00000000..ebcbe816 --- /dev/null +++ b/packages/docs/pages/public/favicons/notion.svg @@ -0,0 +1,7 @@ + + + + + + +