diff --git a/packages/api/src/resolvers/integrations/index.ts b/packages/api/src/resolvers/integrations/index.ts index d50992b28..d0bfc45d1 100644 --- a/packages/api/src/resolvers/integrations/index.ts +++ b/packages/api/src/resolvers/integrations/index.ts @@ -34,7 +34,6 @@ import { import { analytics } from '../../utils/analytics' import { deleteTask, - enqueueExportAllItems, enqueueImportFromIntegration, } from '../../utils/createTask' import { authorized } from '../../utils/gql-utils' @@ -85,40 +84,6 @@ export const setIntegrationResolver = authorized< // save integration const integration = await saveIntegration(integrationToSave, uid) - if (integrationToSave.type === IntegrationType.Export && !input.id) { - const authToken = await createIntegrationToken({ - uid, - token: integration.token, - }) - if (!authToken) { - log.error('failed to create auth token', { - integrationId: integration.id, - }) - return { - errorCodes: [SetIntegrationErrorCode.BadRequest], - } - } - - // create a task to sync all the pages if new integration or enable integration (export type) - await enqueueExportAllItems(integration.id, uid) - } else if (integrationToSave.taskName) { - // delete the task if disable integration and task exists - const result = await deleteTask(integrationToSave.taskName) - if (result) { - log.info('task deleted', integrationToSave.taskName) - } - - // update task name in integration - await updateIntegration( - integration.id, - { - taskName: null, - }, - uid - ) - integration.taskName = null - } - analytics.capture({ distinctId: uid, event: 'integration_set', diff --git a/packages/api/src/services/integrations/index.ts b/packages/api/src/services/integrations/index.ts index a925a2765..2f2b99f0d 100644 --- a/packages/api/src/services/integrations/index.ts +++ b/packages/api/src/services/integrations/index.ts @@ -80,7 +80,11 @@ export const saveIntegration = async ( userId: string ) => { return authTrx( - async (t) => t.getRepository(Integration).save(integration), + async (t) => { + const repo = t.getRepository(Integration) + const newIntegration = await repo.save(integration) + return repo.findOneByOrFail({ id: newIntegration.id }) + }, undefined, userId ) diff --git a/packages/api/test/resolvers/integrations.test.ts b/packages/api/test/resolvers/integrations.test.ts index 992cfe9ca..538841a53 100644 --- a/packages/api/test/resolvers/integrations.test.ts +++ b/packages/api/test/resolvers/integrations.test.ts @@ -222,17 +222,6 @@ describe('Integrations resolvers', () => { expect(res.body.data.setIntegration.integration.enabled).to.be .false }) - - it('deletes cloud task', async () => { - const res = await graphqlRequest( - query(integrationId, integrationName, token, enabled), - authToken - ) - const integration = await findIntegration({ - id: res.body.data.setIntegration.integration.id, - }, loginUser.id) - expect(integration?.taskName).to.be.null - }) }) context('when enable is true', () => { diff --git a/packages/web/components/templates/Beta.tsx b/packages/web/components/templates/Beta.tsx new file mode 100644 index 000000000..00b8b473d --- /dev/null +++ b/packages/web/components/templates/Beta.tsx @@ -0,0 +1,5 @@ +import { Alert } from 'antd' + +export function Beta(): JSX.Element { + return +} diff --git a/packages/web/components/templates/integrations/Readwise.tsx b/packages/web/components/templates/integrations/Readwise.tsx index 4e0469896..353687e1b 100644 --- a/packages/web/components/templates/integrations/Readwise.tsx +++ b/packages/web/components/templates/integrations/Readwise.tsx @@ -1,27 +1,18 @@ -import { useCallback, useMemo, useState } from 'react' -import { styled } from '@stitches/react' import Image from 'next/image' - -import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives' -import { Button } from '../../elements/Button' -import { StyledText } from '../../elements/StyledText' -import { FormInput } from '../../elements/FormElements' - +import { useRouter } from 'next/router' +import { useCallback, useMemo, useState } from 'react' +import { deleteIntegrationMutation } from '../../../lib/networking/mutations/deleteIntegrationMutation' import { setIntegrationMutation } from '../../../lib/networking/mutations/setIntegrationMutation' import { Integration, useGetIntegrationsQuery, } from '../../../lib/networking/queries/useGetIntegrationsQuery' -import { useRouter } from 'next/router' import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers' -import { deleteIntegrationMutation } from '../../../lib/networking/mutations/deleteIntegrationMutation' - -// Styles -const Header = styled(Box, { - color: '$utilityTextDefault', - fontSize: 'x-large', - margin: '20px', -}) +import { Button } from '../../elements/Button' +import { FormInput } from '../../elements/FormElements' +import { HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives' +import { StyledText } from '../../elements/StyledText' +import { Header } from '../settings/SettingsTable' export function Readwise(): JSX.Element { const { integrations, revalidate } = useGetIntegrationsQuery() diff --git a/packages/web/components/templates/settings/SettingsTable.tsx b/packages/web/components/templates/settings/SettingsTable.tsx index 7e87c9a7a..1db5872a6 100644 --- a/packages/web/components/templates/settings/SettingsTable.tsx +++ b/packages/web/components/templates/settings/SettingsTable.tsx @@ -6,11 +6,18 @@ import { MoreOptionsIcon } from '../../elements/images/MoreOptionsIcon' import { InfoLink } from '../../elements/InfoLink' import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives' import { StyledText } from '../../elements/StyledText' -import { theme } from '../../tokens/stitches.config' +import { styled, theme } from '../../tokens/stitches.config' import { SettingsLayout } from '../SettingsLayout' import { usePersistedState } from '../../../lib/hooks/usePersistedState' import { FeatureHelpBox } from '../../elements/FeatureHelpBox' +// Styles +export const Header = styled(Box, { + color: '$utilityTextDefault', + fontSize: 'x-large', + margin: '20px', +}) + type SettingsTableProps = { pageId: string pageInfoLink?: string | undefined diff --git a/packages/web/lib/networking/mutations/setIntegrationMutation.ts b/packages/web/lib/networking/mutations/setIntegrationMutation.ts index 66434ae7c..0c0bc4b6d 100644 --- a/packages/web/lib/networking/mutations/setIntegrationMutation.ts +++ b/packages/web/lib/networking/mutations/setIntegrationMutation.ts @@ -16,6 +16,7 @@ export type SetIntegrationInput = { token: string enabled: boolean importItemState?: ImportItemState + settings?: any } type SetIntegrationResult = { @@ -52,6 +53,7 @@ export async function setIntegrationMutation( enabled createdAt updatedAt + settings } } ... on SetIntegrationError { diff --git a/packages/web/lib/networking/queries/useGetIntegrationsQuery.tsx b/packages/web/lib/networking/queries/useGetIntegrationsQuery.tsx index 9c28e2505..abd3e3bab 100644 --- a/packages/web/lib/networking/queries/useGetIntegrationsQuery.tsx +++ b/packages/web/lib/networking/queries/useGetIntegrationsQuery.tsx @@ -11,6 +11,7 @@ export interface Integration { createdAt: Date updatedAt: Date taskName?: string + settings?: any } export type IntegrationType = 'EXPORT' | 'IMPORT' @@ -43,6 +44,7 @@ export function useGetIntegrationsQuery(): IntegrationsQueryResponse { createdAt updatedAt taskName + settings } } ... on IntegrationsError { diff --git a/packages/web/next.config.js b/packages/web/next.config.js index e4d6e85dc..07228c7f9 100644 --- a/packages/web/next.config.js +++ b/packages/web/next.config.js @@ -3,7 +3,7 @@ const ContentSecurityPolicy = ` base-uri 'self'; connect-src 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://proxy-prod.omnivore-image-cache.app https://accounts.google.com https://proxy-demo.omnivore-image-cache.app https://storage.googleapis.com https://api.segment.io https://cdn.segment.com https://widget.intercom.io https://api-iam.intercom.io https://static.intercomassets.com https://downloads.intercomcdn.com https://platform.twitter.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io wss://nexus-europe-websocket.intercom.io wss://nexus-australia-websocket.intercom.io https://uploads.intercomcdn.com https://tools.applemediaservices.com; font-src 'self' data: https://cdn.jsdelivr.net https://js.intercomcdn.com https://fonts.intercomcdn.com; - form-action 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://getpocket.com/auth/authorize https://intercom.help https://api-iam.intercom.io https://api-iam.eu.intercom.io https://api-iam.au.intercom.io; + form-action 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://getpocket.com/auth/authorize https://intercom.help https://api-iam.intercom.io https://api-iam.eu.intercom.io https://api-iam.au.intercom.io https://www.notion.so https://api.notion.com; frame-ancestors 'none'; frame-src 'self' https://accounts.google.com https://platform.twitter.com https://www.youtube.com https://www.youtube-nocookie.com; manifest-src 'self'; diff --git a/packages/web/pages/settings/integrations.tsx b/packages/web/pages/settings/integrations.tsx index 4d549b217..c9f2e3296 100644 --- a/packages/web/pages/settings/integrations.tsx +++ b/packages/web/pages/settings/integrations.tsx @@ -89,6 +89,9 @@ export default function Integrations(): JSX.Element { const pocketConnected = useMemo(() => { return integrations.find((i) => i.name == 'POCKET' && i.type == 'IMPORT') }, [integrations]) + const isConnected = (name: string) => { + return integrations.find((i) => i.name == name)?.enabled + } const deleteIntegration = async (id: string) => { try { @@ -110,17 +113,23 @@ export default function Integrations(): JSX.Element { } } - const redirectToPocket = (importItemState: ImportItemState) => { + const redirectToIntegration = ( + name: string, + importItemState?: ImportItemState + ) => { // create a form and submit it to the backend const form = document.createElement('form') form.method = 'POST' - form.action = `${fetchEndpoint}/integration/pocket/auth` - const input = document.createElement('input') - input.type = 'hidden' - input.name = 'state' - input.value = importItemState - form.appendChild(input) + form.action = `${fetchEndpoint}/integration/${name.toLowerCase()}/auth` + if (importItemState) { + const input = document.createElement('input') + input.type = 'hidden' + input.name = 'state' + input.value = importItemState + form.appendChild(input) + } document.body.appendChild(form) + form.submit() } @@ -155,13 +164,41 @@ export default function Integrations(): JSX.Element { { duration: 5000 } ) } finally { - router.replace('/settings/integrations') + router.push('/settings/integrations') } } + + const connectWithNotion = async () => { + try { + // get the token from query string + const token = router.query.code as string + await setIntegrationMutation({ + token, + name: 'NOTION', + type: 'EXPORT', + enabled: true, + }) + + showSuccessToast('Connected with Notion.') + + router.push('/settings/integrations/notion') + } catch (err) { + showErrorToast( + 'There was an error connecting to Notion. Please try again.', + { duration: 5000 } + ) + + router.push('/settings/integrations') + } + } + if (!router.isReady) return if (router.query.pocketToken && router.query.state && !pocketConnected) { connectToPocket() } + if (router.query.code) { + connectWithNotion() + } }, [router]) useEffect(() => { @@ -210,7 +247,7 @@ export default function Integrations(): JSX.Element { action: () => { pocketConnected ? deleteIntegration(pocketConnected.id) - : redirectToPocket(ImportItemState.Unarchived) + : redirectToIntegration('pocket', ImportItemState.Unarchived) }, disabled: isImporting(pocketConnected), isDropdown: !pocketConnected, @@ -218,18 +255,34 @@ export default function Integrations(): JSX.Element { { text: 'Import All', action: () => { - redirectToPocket(ImportItemState.All) + redirectToIntegration('pocket', ImportItemState.All) }, }, { text: 'Import Unarchived', action: () => { - redirectToPocket(ImportItemState.Unarchived) + redirectToIntegration('pocket', ImportItemState.Unarchived) }, }, ], }, }, + { + icon: '/static/icons/notion.png', + title: 'Notion', + subText: + 'Notion is an all-in-one workspace. Use our Notion integration to sync your Omnivore items to Notion.', + button: { + text: isConnected('NOTION') ? 'Settings' : 'Connect', + icon: , + style: isConnected('NOTION') ? 'ctaWhite' : 'ctaDarkYellow', + action: () => { + isConnected('NOTION') + ? router.push('/settings/integrations/notion') + : redirectToIntegration('NOTION') + }, + }, + }, { icon: '/static/icons/webhooks.svg', title: 'Webhooks', @@ -258,7 +311,7 @@ export default function Integrations(): JSX.Element { }, }, ]) - }, [pocketConnected, readwiseConnected, webhooks]) + }, [pocketConnected, readwiseConnected, webhooks, integrations]) return ( diff --git a/packages/web/pages/settings/integrations/notion.tsx b/packages/web/pages/settings/integrations/notion.tsx new file mode 100644 index 000000000..eb56fa892 --- /dev/null +++ b/packages/web/pages/settings/integrations/notion.tsx @@ -0,0 +1,207 @@ +import { + Button, + Checkbox, + Form, + FormProps, + Input, + message, + Space, + Switch, +} from 'antd' +import 'antd/dist/antd.compact.css' +import { CheckboxValueType } from 'antd/lib/checkbox/Group' +import Image from 'next/image' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { HStack, VStack } from '../../../components/elements/LayoutPrimitives' +import { PageMetaData } from '../../../components/patterns/PageMetaData' +import { Beta } from '../../../components/templates/Beta' +import { Header } from '../../../components/templates/settings/SettingsTable' +import { SettingsLayout } from '../../../components/templates/SettingsLayout' +import { deleteIntegrationMutation } from '../../../lib/networking/mutations/deleteIntegrationMutation' +import { setIntegrationMutation } from '../../../lib/networking/mutations/setIntegrationMutation' +import { + Integration, + useGetIntegrationsQuery, +} from '../../../lib/networking/queries/useGetIntegrationsQuery' +import { applyStoredTheme } from '../../../lib/themeUpdater' +import { showSuccessToast } from '../../../lib/toastHelpers' + +type FieldType = { + parentPageId?: string + parentDatabaseId?: string + autoSync?: boolean + properties?: string[] +} + +export default function Notion(): JSX.Element { + applyStoredTheme() + + const router = useRouter() + const { integrations, revalidate } = useGetIntegrationsQuery() + const [notion, setNotion] = useState() + + const [form] = Form.useForm() + const [messageApi, contextHolder] = message.useMessage() + + useEffect(() => { + const notion = integrations.find( + (i) => i.name == 'NOTION' && i.type == 'EXPORT' + ) + + if (notion) { + setNotion(notion) + + form.setFieldsValue({ + parentPageId: notion.settings?.parentPageId, + parentDatabaseId: notion.settings?.parentDatabaseId, + autoSync: notion.settings?.autoSync, + properties: notion.settings?.properties, + }) + } + }, [form, integrations]) + + const deleteNotion = async () => { + if (!notion) { + throw new Error('Notion integration not found') + } + + await deleteIntegrationMutation(notion.id) + showSuccessToast('Notion integration disconnected successfully.') + + router.push('/settings/integrations') + } + + const updateNotion = async (values: FieldType) => { + if (!notion) { + throw new Error('Notion integration not found') + } + + await setIntegrationMutation({ + id: notion.id, + name: notion.name, + type: notion.type, + token: notion.token, + enabled: notion.enabled, + settings: values, + }) + } + + const onFinish: FormProps['onFinish'] = async (values) => { + try { + await updateNotion(values) + + revalidate() + messageApi.success('Notion settings updated successfully.') + } catch (error) { + messageApi.error('There was an error updating Notion settings.') + } + } + + const onFinishFailed: FormProps['onFinishFailed'] = ( + errorInfo + ) => { + console.log('Failed:', errorInfo) + } + + const onDataChange = (value: Array) => { + form.setFieldsValue({ properties: value.map((v) => v.toString()) }) + } + + return ( + <> + {contextHolder} + + + + + Integration Image +
Notion integration settings
+ +
+ + {notion && ( +
+
+ + label="Notion Page Id" + name="parentPageId" + rules={[ + { + required: true, + message: 'Please input your Notion Page Id!', + }, + ]} + > + + + + + label="Notion Database Id" + name="parentDatabaseId" + hidden + > + + + + + label="Automatic Sync" + name="autoSync" + valuePropName="checked" + > + + + + + label="Properties to Export" + name="properties" + > + + Highlights + Labels + Notes + + + + + + + + + + +
+ )} +
+
+ + ) +} diff --git a/packages/web/public/static/icons/notion.png b/packages/web/public/static/icons/notion.png new file mode 100644 index 000000000..391051679 Binary files /dev/null and b/packages/web/public/static/icons/notion.png differ