diff --git a/.env.example b/.env.example index b91af94fcd..a6b25e768a 100644 --- a/.env.example +++ b/.env.example @@ -106,6 +106,10 @@ CRON_SECRET= # Configure this when you want to ship JS & CSS files from a complete URL instead of the current domain # ASSET_PREFIX_URL= +# Oauth credentials for Notion Integration +NOTION_OAUTH_CLIENT_ID= +NOTION_OAUTH_CLIENT_SECRET= + # Stripe Billing Variables STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= diff --git a/apps/formbricks-com/app/docs/integrations/google-sheets/page.mdx b/apps/formbricks-com/app/docs/integrations/google-sheets/page.mdx index ff3380ea3c..899270c1fc 100644 --- a/apps/formbricks-com/app/docs/integrations/google-sheets/page.mdx +++ b/apps/formbricks-com/app/docs/integrations/google-sheets/page.mdx @@ -10,8 +10,9 @@ import DeleteConnection from "./delete-connection.webp"; import Image from "next/image"; export const metadata = { - title: "n8n Setup", - description: "Wire up Formbricks with n8n and 350+ other apps", + title: "Google Sheets", + description: + "The Google Sheets integration allows you to automatically send responses to a Google Sheet of your choice.", }; #### Integrations @@ -62,7 +63,7 @@ Before the next step, make sure that you have a Formbricks Survey with at least -6. Now click on the "Link New Sheet" button to link a new Google Sheet with Formbricks and a modal will open up. +5. Now click on the "Link New Sheet" button to link a new Google Sheet with Formbricks and a modal will open up. -7. Select the Google Sheet you want to link with Formbricks and the Survey. On doing so, you will be asked with what questions' responses you want to feed in the Google Sheet. Select the questions and click on the "Link Sheet" button. +6. Select the Google Sheet you want to link with Formbricks and the Survey. On doing so, you will be asked with what questions' responses you want to feed in the Google Sheet. Select the questions and click on the "Link Sheet" button. Select question to link with Google Sheet -8. On submitting, the modal will close and you will see the linked Google Sheet in the list of linked Google Sheets. +7. On submitting, the modal will close and you will see the linked Google Sheet in the list of linked Google Sheets. + This feature is enabled by default in Formbricks Cloud but needs to be self-configured when running a + self-hosted version of Formbricks. + + +## Formbricks Cloud + +1. Go to the Integrations tab in your [Formbricks Cloud dashboard](https://app.formbricks.com/) and click on the "Connect" button under Notion integration. + +Formbricks Integrations Tab + +2. Now click on the "Connect with Notion" button to authenticate yourself with Notion. + +Connect Formbricks with your Notion account + +3. You will now be taken to the Notion OAuth page where you can select the Notion account you want to use for the integration + +4. Once you have selected the account and databases and completed the authentication and authorization process, you will be taken back to Formbricks Cloud and see the connected status as below: + +Formbricks is now connected with Notion + + + Before the next step, make sure that you have a Formbricks Survey with at least one question and a Notion + database in the Notion account you integrated. + + +5. Now click on the "Link New Database" button to link a new Notion database with Formbricks and a modal will open up. + +Link Formbricks with a Notion database + +6. Select the Notion database you want to link with Formbricks and the Survey. On doing so, you will be asked to map formbricks' survey questions with selected databases' column. Complete the mapping and click on the "Link Database" button. + +Question to notion database column mapping + +7. On submitting, the modal will close and you will see the linked Notion database in the list of linked Notion databases. + +List of linked notion databases + +Congratulations! You have successfully linked a Notion database with Formbricks. Now whenever a response is submitted for the linked Survey, it will be automatically added to the linked Notion database. + +## Setup in self-hosted Formbricks + +Enabling the Notion Integration in a self-hosted environment requires a setup using Notion account and changing the environment variables of your Formbricks instance. + +1. Sign up for a [Notion](https://www.notion.so/) account, if you don't have one already. +2. Go to the [my integrations](https://www.notion.so/my-integrations) page and click on **New integration**. +3. Fill up the basic information like **Name**, **Logo** and click on **Submit**. +4. Now, click on **Distribution** tab on the sidebar. A text will appear which will ask you to make the integration public. Click on that toggle button. A form will appear below the text. +5. Now provide it the details such as requested. Under **Redirect URIs** field: + - If you are running formbricks locally, you can enter `http://localhost:3000/api/v1/integrations/notion/callback`. + - Or, you can enter `https:///api/v1/integrations/notion/callback` +6. Once you've filled all the necessary details, click on **Submit**. +7. A screen will appear which will have **Client ID**, **Client secret** and **Authorization URL**. Copy them and set them as the environment variables in your Formbricks instance as: + - `NOTION_OAUTH_CLIENT_ID` - OAuth Client ID + - `NOTION_OAUTH_CLIENT_SECRET` - OAuth Client Secret + +Voila! You have successfully enabled the Notion integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in the [Formbricks Cloud](#formbricks-cloud) section to link a Notion database with Formbricks. + +## Remove Integration with Notion Account + +To remove the integration with Notion Account, + +1. Visit the Integrations tab in your Formbricks Cloud dashboard. +2. Select "Manage" button in the Notion card. +3. Click on the "Connected with ` Workspace" just before the "Link new Database" button. +4. It will now ask for a confirmation to remove the integration. Click on the "Delete" button to remove the integration. You can always come back and connect again with the same Notion Account. + +Delete Notion Integration with Formbricks + +Still struggling or something not working as expected? [Join our Discord!](https://formbricks.com/discord) and we'd be glad to assist you! diff --git a/apps/formbricks-com/components/docs/Navigation.tsx b/apps/formbricks-com/components/docs/Navigation.tsx index d67a90820b..a91fa439b6 100644 --- a/apps/formbricks-com/components/docs/Navigation.tsx +++ b/apps/formbricks-com/components/docs/Navigation.tsx @@ -237,6 +237,7 @@ export const navigation: Array = [ links: [ { title: "Airtable", href: "/docs/integrations/airtable" }, { title: "Google Sheets", href: "/docs/integrations/google-sheets" }, + { title: "Notion", href: "/docs/integrations/notion" }, { title: "Make.com", href: "/docs/integrations/make" }, { title: "n8n", href: "/docs/integrations/n8n" }, { title: "Zapier", href: "/docs/integrations/zapier" }, diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/actions.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/actions.ts new file mode 100644 index 0000000000..cacf087609 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/actions.ts @@ -0,0 +1,18 @@ +"use server"; + +import { getServerSession } from "next-auth"; + +import { authOptions } from "@formbricks/lib/authOptions"; +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { getNotionDatabases } from "@formbricks/lib/notion/service"; +import { AuthorizationError } from "@formbricks/types/errors"; + +export async function refreshDatabasesAction(environmentId: string) { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + + return await getNotionDatabases(environmentId); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx new file mode 100644 index 0000000000..7652a8fd7b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx @@ -0,0 +1,589 @@ +import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; +import { + ERRORS, + TYPE_MAPPING, + UNSUPPORTED_TYPES_BY_NOTION, +} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants"; +import { questionTypes } from "@/app/lib/questions"; +import NotionLogo from "@/images/notion.png"; +import { ArrowPathIcon, ChevronDownIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/solid"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import Image from "next/image"; +import React, { useEffect, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import toast from "react-hot-toast"; + +import { TIntegrationInput } from "@formbricks/types/integration"; +import { + TIntegrationNotion, + TIntegrationNotionConfigData, + TIntegrationNotionDatabase, +} from "@formbricks/types/integration/notion"; +import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; +import { Label } from "@formbricks/ui/Label"; +import { Modal } from "@formbricks/ui/Modal"; + +interface AddIntegrationModalProps { + environmentId: string; + surveys: TSurvey[]; + open: boolean; + setOpen: React.Dispatch>; + notionIntegration: TIntegrationNotion; + databases: TIntegrationNotionDatabase[]; + selectedIntegration: (TIntegrationNotionConfigData & { index: number }) | null; +} + +export default function AddIntegrationModal({ + environmentId, + surveys, + open, + setOpen, + notionIntegration, + databases, + selectedIntegration, +}: AddIntegrationModalProps) { + const { handleSubmit } = useForm(); + const [selectedDatabase, setSelectedDatabase] = useState(); + const [selectedSurvey, setSelectedSurvey] = useState(null); + const [mapping, setMapping] = useState< + { + column: { id: string; name: string; type: string }; + question: { id: string; name: string; type: string }; + error?: { + type: string; + msg: React.ReactNode | string; + } | null; + }[] + >([ + { + column: { id: "", name: "", type: "" }, + question: { id: "", name: "", type: "" }, + }, + ]); + const [isDeleting, setIsDeleting] = useState(null); + const [isLinkingDatabase, setIsLinkingDatabase] = useState(false); + const integrationData = { + databaseId: "", + databaseName: "", + surveyId: "", + surveyName: "", + mapping: [ + { + column: { id: "", name: "", type: "" }, + question: { id: "", name: "", type: "" }, + }, + ], + createdAt: new Date(), + }; + + const notionIntegrationData: TIntegrationInput = { + type: "notion", + config: { + key: notionIntegration?.config?.key, + data: notionIntegration.config?.data || [], + }, + }; + + const hasMatchingId = notionIntegration.config.data.some((configData) => { + if (!selectedDatabase) { + return false; + } + return configData.databaseId === selectedDatabase.id; + }); + + const dbItems = useMemo(() => { + const dbProperties = (selectedDatabase as any)?.properties; + return ( + Object.keys(dbProperties || {}).map((fieldKey: string) => ({ + id: dbProperties[fieldKey].id, + name: dbProperties[fieldKey].name, + type: dbProperties[fieldKey].type, + })) || [] + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDatabase?.id]); + + const questionItems = useMemo(() => { + const questions = + selectedSurvey?.questions.map((q) => ({ + id: q.id, + name: q.headline, + type: q.type, + })) || []; + + const hiddenFields = selectedSurvey?.hiddenFields.enabled + ? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({ + id: fId, + name: fId, + type: TSurveyQuestionType.OpenText, + })) || [] + : []; + return [...questions, ...hiddenFields]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedSurvey?.id]); + + useEffect(() => { + if (selectedIntegration) { + const selectedDB = databases.find((db) => db.id === selectedIntegration.databaseId)!; + if (selectedDB) { + setSelectedDatabase({ + id: selectedDB.id, + name: (selectedDB as any).title?.[0]?.plain_text, + properties: selectedDB.properties, + }); + } + setSelectedSurvey( + surveys.find((survey) => { + return survey.id === selectedIntegration.surveyId; + })! + ); + setMapping(selectedIntegration.mapping); + return; + } + resetForm(); + }, [selectedIntegration, surveys, databases]); + + const linkDatabase = async () => { + try { + if (!selectedDatabase) { + throw new Error("Please select a database"); + } + if (!selectedSurvey) { + throw new Error("Please select a survey"); + } + + if (mapping.length === 1 && (!mapping[0].question.id || !mapping[0].column.id)) { + throw new Error("Please select at least one mapping"); + } + + if (mapping.filter((m) => m.error).length > 0) { + throw new Error("Please resolve the mapping errors"); + } + + if ( + mapping.filter((m) => m.column.id && !m.question.id).length >= 1 || + mapping.filter((m) => m.question.id && !m.column.id).length >= 1 + ) { + throw new Error("Please complete mapping fields with notion property"); + } + + setIsLinkingDatabase(true); + + integrationData.databaseId = selectedDatabase.id; + integrationData.databaseName = selectedDatabase.name; + integrationData.surveyId = selectedSurvey.id; + integrationData.surveyName = selectedSurvey.name; + integrationData.mapping = mapping.map((m) => { + delete m.error; + return m; + }); + integrationData.createdAt = new Date(); + + if (selectedIntegration) { + // update action + notionIntegrationData.config!.data[selectedIntegration.index] = integrationData; + } else { + // create action + notionIntegrationData.config!.data.push(integrationData); + } + + await createOrUpdateIntegrationAction(environmentId, notionIntegrationData); + toast.success(`Integration ${selectedIntegration ? "updated" : "added"} successfully`); + resetForm(); + setOpen(false); + } catch (e) { + toast.error(e.message); + } finally { + setIsLinkingDatabase(false); + } + }; + + const deleteLink = async () => { + notionIntegrationData.config!.data.splice(selectedIntegration!.index, 1); + try { + setIsDeleting(true); + await createOrUpdateIntegrationAction(environmentId, notionIntegrationData); + toast.success("Integration removed successfully"); + setOpen(false); + } catch (error) { + toast.error(error.message); + } finally { + setIsDeleting(false); + } + }; + + const resetForm = () => { + setIsLinkingDatabase(false); + setSelectedDatabase(null); + setSelectedSurvey(null); + }; + const getFilteredQuestionItems = (selectedIdx) => { + const selectedQuestionIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.question.id); + + return questionItems.filter((q) => !selectedQuestionIds.includes(q.id)); + }; + + const createCopy = (item) => JSON.parse(JSON.stringify(item)); + + const MappingRow = ({ idx }: { idx: number }) => { + const filteredQuestionItems = getFilteredQuestionItems(idx); + + const addRow = () => { + setMapping((prev) => [ + ...prev, + { + column: { id: "", name: "", type: "" }, + question: { id: "", name: "", type: "" }, + }, + ]); + }; + + const deleteRow = () => { + setMapping((prev) => { + return prev.filter((_, i) => i !== idx); + }); + }; + + const ErrorMsg = ({ error, col, ques }) => { + const showErrorMsg = useMemo(() => { + switch (error?.type) { + case ERRORS.UNSUPPORTED_TYPE: + return ( + <> + - {col.name} of type {col.type} is not supported by notion API. The data + won't be reflected in your notion database. + + ); + case ERRORS.MAPPING: + return ( + <> + - "{ques.name}" of type{" "} + {questionTypes.find((qt) => qt.id === ques.type)?.label} can't be mapped to the + column "{col.name}" of type {col.type} + + ); + default: + return null; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [error]); + + if (!error) return null; + + return ( +
+ {error.type} + {showErrorMsg} +
+ ); + }; + + const getFilteredDbItems = () => { + const colMapping = mapping.map((m) => m.column.id); + return dbItems.filter((item) => !colMapping.includes(item.id)); + }; + + return ( +
+ +
+
+
+ { + setMapping((prev) => { + const copy = createCopy(prev); + const col = copy[idx].column; + if (col.id) { + if (UNSUPPORTED_TYPES_BY_NOTION.includes(col.type)) { + copy[idx] = { + ...copy[idx], + error: { + type: ERRORS.UNSUPPORTED_TYPE, + }, + question: item, + }; + return copy; + } + + const isValidColType = TYPE_MAPPING[item.type].includes(col.type); + if (!isValidColType) { + copy[idx] = { + ...copy[idx], + error: { + type: ERRORS.MAPPING, + }, + question: item, + }; + return copy; + } + } + + copy[idx] = { + ...copy[idx], + question: item, + error: null, + }; + return copy; + }); + }} + disabled={questionItems.length === 0} + /> +
+
+
+ { + setMapping((prev) => { + const copy = createCopy(prev); + const ques = copy[idx].question; + if (ques.id) { + const isValidQuesType = TYPE_MAPPING[ques.type].includes(item.type); + + if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) { + copy[idx] = { + ...copy[idx], + error: { + type: ERRORS.UNSUPPORTED_TYPE, + }, + column: item, + }; + return copy; + } + + if (!isValidQuesType) { + copy[idx] = { + ...copy[idx], + error: { + type: ERRORS.MAPPING, + }, + column: item, + }; + return copy; + } + } + copy[idx] = { + ...copy[idx], + column: item, + error: null, + }; + return copy; + }); + }} + disabled={dbItems.length === 0} + /> +
+
+ + +
+
+ ); + }; + + return ( + +
+
+
+
+
+ Google Sheet logo +
+
+
Link Notion Database
+
Sync responses with a Notion Database
+
+
+
+
+
+
+
+
+
+ ({ + id: d.id, + name: (d as any).title?.[0]?.plain_text, + properties: d.properties, + }))} + selectedItem={selectedDatabase} + setSelectedItem={setSelectedDatabase} + disabled={databases.length === 0} + /> + {selectedDatabase && hasMatchingId && ( +

+ Warning: A connection with this database is live. Please make changes + with caution. +

+ )} +

+ {databases.length === 0 && + "You have to create at least one database to be able to setup this integration"} +

+
+
+ +

+ {surveys.length === 0 && + "You have to create a survey to be able to setup this integration"} +

+
+ {selectedDatabase && selectedSurvey && ( +
+ +
+ {mapping.map((_, idx) => ( + + ))} +
+
+ )} +
+
+
+
+
+ {selectedIntegration ? ( + + ) : ( + + )} + +
+
+
+
+
+ ); +} + +interface DropdownSelectorProps { + label?: string; + items: Array; + selectedItem: any; + setSelectedItem: React.Dispatch>; + disabled: boolean; + placeholder?: string; + refetch?: () => void; +} + +const DropdownSelector = ({ + label, + items, + selectedItem, + setSelectedItem, + disabled, + placeholder, + refetch, +}: DropdownSelectorProps) => { + return ( +
+ {label && } +
+ + + + + + {!disabled && ( + + + {items && + items.map((item) => ( + setSelectedItem(item)}> + {item.name} + + ))} + + + )} + + {refetch && ( + + )} +
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/Connect.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/Connect.tsx new file mode 100644 index 0000000000..4125f3e356 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/Connect.tsx @@ -0,0 +1,69 @@ +import FormbricksLogo from "@/images/logo.svg"; +import NotionLogo from "@/images/notion.png"; +import Image from "next/image"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; + +import { Button } from "@formbricks/ui/Button"; + +import { authorize } from "../lib/notion"; + +interface ConnectProps { + enabled: boolean; + environmentId: string; + webAppUrl: string; +} + +export default function Connect({ enabled, environmentId, webAppUrl }: ConnectProps) { + const [isConnecting, setIsConnecting] = useState(false); + const searchParams = useSearchParams(); + + useEffect(() => { + const error = searchParams?.get("error"); + if (error) { + toast.error("Connecting integration failed. Please try again!"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleAuthorizeNotion = async () => { + setIsConnecting(true); + authorize(environmentId, webAppUrl).then((url: string) => { + if (url) { + window.location.replace(url); + } + }); + }; + + return ( +
+
+
+
+ Formbricks Logo +
+
+ Google Sheet logo +
+
+

Sync responses directly with your Notion database.

+ {!enabled && ( +

+ Notion Integration is not configured in your instance of Formbricks. +
+ Please follow the{" "} + + docs + {" "} + to configure it. +

+ )} + +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/Home.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/Home.tsx new file mode 100644 index 0000000000..b05cd057ed --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/Home.tsx @@ -0,0 +1,125 @@ +import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; +import React, { useState } from "react"; +import toast from "react-hot-toast"; + +import { timeSince } from "@formbricks/lib/time"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion"; +import { Button } from "@formbricks/ui/Button"; +import { DeleteDialog } from "@formbricks/ui/DeleteDialog"; +import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller"; + +interface HomeProps { + environment: TEnvironment; + notionIntegration: TIntegrationNotion; + setOpenAddIntegrationModal: React.Dispatch>; + setIsConnected: React.Dispatch>; + setSelectedIntegration: (v: (TIntegrationNotionConfigData & { index: number }) | null) => void; +} + +export default function Home({ + environment, + notionIntegration, + setOpenAddIntegrationModal, + setIsConnected, + setSelectedIntegration, +}: HomeProps) { + const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false); + const [isDeleting, setisDeleting] = useState(false); + const integrationArray = notionIntegration + ? notionIntegration.config.data + ? notionIntegration.config.data + : [] + : []; + + const handleDeleteIntegration = async () => { + try { + setisDeleting(true); + await deleteIntegrationAction(notionIntegration.id); + setIsConnected(false); + toast.success("Integration removed successfully"); + } catch (error) { + toast.error(error.message); + } finally { + setisDeleting(false); + setIsDeleteIntegrationModalOpen(false); + } + }; + + const editIntegration = (index: number) => { + setSelectedIntegration({ + ...notionIntegration.config.data[index], + index: index, + }); + setOpenAddIntegrationModal(true); + }; + + return ( +
+
+
+ + { + setIsDeleteIntegrationModalOpen(true); + }}> + Connected with {notionIntegration.config.key.workspace_name} workspace + +
+ +
+ {!integrationArray || integrationArray.length === 0 ? ( +
+ +
+ ) : ( +
+
+
+
Survey
+
Database Name
+
Updated At
+
+ {integrationArray && + integrationArray.map((data, index) => { + return ( +
{ + editIntegration(index); + }}> +
{data.surveyName}
+
{data.databaseName}
+
{timeSince(data.createdAt.toString())}
+
+ ); + })} +
+
+ )} + + +
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper.tsx new file mode 100644 index 0000000000..34abffd695 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper.tsx @@ -0,0 +1,67 @@ +"use client"; + +import AddIntegrationModal from "@/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal"; +import Connect from "@/app/(app)/environments/[environmentId]/integrations/notion/components/Connect"; +import Home from "@/app/(app)/environments/[environmentId]/integrations/notion/components/Home"; +import { useState } from "react"; + +import { TEnvironment } from "@formbricks/types/environment"; +import { + TIntegrationNotion, + TIntegrationNotionConfigData, + TIntegrationNotionDatabase, +} from "@formbricks/types/integration/notion"; +import { TSurvey } from "@formbricks/types/surveys"; + +interface NotionWrapperProps { + notionIntegration: TIntegrationNotion | undefined; + enabled: boolean; + environment: TEnvironment; + webAppUrl: string; + surveys: TSurvey[]; + databasesArray: TIntegrationNotionDatabase[]; +} + +export default function NotionWrapper({ + notionIntegration, + enabled, + environment, + webAppUrl, + surveys, + databasesArray, +}: NotionWrapperProps) { + const [isModalOpen, setModalOpen] = useState(false); + const [isConnected, setIsConnected] = useState( + notionIntegration ? notionIntegration.config.key?.bot_id : false + ); + const [selectedIntegration, setSelectedIntegration] = useState< + (TIntegrationNotionConfigData & { index: number }) | null + >(null); + + return ( + <> + {isConnected && notionIntegration ? ( + <> + + + + ) : ( + + )} + + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/constants.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/constants.ts new file mode 100644 index 0000000000..3d9fbf0bdb --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/constants.ts @@ -0,0 +1,38 @@ +import { TSurveyQuestionType } from "@formbricks/types/surveys"; + +export const TYPE_MAPPING = { + [TSurveyQuestionType.CTA]: ["checkbox"], + [TSurveyQuestionType.MultipleChoiceMulti]: ["multi_select"], + [TSurveyQuestionType.MultipleChoiceSingle]: ["select", "status"], + [TSurveyQuestionType.OpenText]: [ + "created_by", + "created_time", + "date", + "email", + "last_edited_by", + "last_edited_time", + "number", + "phone_number", + "rich_text", + "title", + "url", + ], + [TSurveyQuestionType.NPS]: ["number"], + [TSurveyQuestionType.Consent]: ["checkbox"], + [TSurveyQuestionType.Rating]: ["number"], + [TSurveyQuestionType.PictureSelection]: ["url"], + [TSurveyQuestionType.FileUpload]: ["url"], +}; + +export const UNSUPPORTED_TYPES_BY_NOTION = [ + "rollup", + "created_by", + "created_time", + "last_edited_by", + "last_edited_time", +]; + +export const ERRORS = { + MAPPING: "Mapping Error", + UNSUPPORTED_TYPE: "Unsupported type by Notion", +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/lib/notion.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/lib/notion.ts new file mode 100644 index 0000000000..5eab694413 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/lib/notion.ts @@ -0,0 +1,14 @@ +export const authorize = async (environmentId: string, apiHost: string): Promise => { + const res = await fetch(`${apiHost}/api/v1/integrations/notion`, { + method: "GET", + headers: { environmentId: environmentId }, + }); + + if (!res.ok) { + console.error(res.text); + throw new Error("Could not create response"); + } + const resJSON = await res.json(); + const authUrl = resJSON.data.authUrl; + return authUrl; +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/loading.tsx new file mode 100644 index 0000000000..11d8cc2179 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/loading.tsx @@ -0,0 +1,59 @@ +import { Button } from "@formbricks/ui/Button"; +import GoBackButton from "@formbricks/ui/GoBackButton"; + +export default function Loading() { + return ( +
+ +
+ +
+ +
+
+
Survey
+
Database Name
+
Updated At
+
+
+ {[...Array(3)].map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx new file mode 100644 index 0000000000..8e463ecc8e --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx @@ -0,0 +1,52 @@ +import NotionWrapper from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper"; + +import { + NOTION_AUTH_URL, + NOTION_OAUTH_CLIENT_ID, + NOTION_OAUTH_CLIENT_SECRET, + NOTION_REDIRECT_URI, + WEBAPP_URL, +} from "@formbricks/lib/constants"; +import { getEnvironment } from "@formbricks/lib/environment/service"; +import { getIntegrationByType } from "@formbricks/lib/integration/service"; +import { getNotionDatabases } from "@formbricks/lib/notion/service"; +import { getSurveys } from "@formbricks/lib/survey/service"; +import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion"; +import GoBackButton from "@formbricks/ui/GoBackButton"; + +export default async function Notion({ params }) { + const enabled = !!( + NOTION_OAUTH_CLIENT_ID && + NOTION_OAUTH_CLIENT_SECRET && + NOTION_AUTH_URL && + NOTION_REDIRECT_URI + ); + const [surveys, notionIntegration, environment] = await Promise.all([ + getSurveys(params.environmentId), + getIntegrationByType(params.environmentId, "notion"), + getEnvironment(params.environmentId), + ]); + + if (!environment) { + throw new Error("Environment not found"); + } + + let databasesArray: TIntegrationNotionDatabase[] = []; + if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) { + databasesArray = await getNotionDatabases(environment.id); + } + + return ( + <> + + + + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx index e8aafa2e81..a8349cf606 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx @@ -1,6 +1,7 @@ import JsLogo from "@/images/jslogo.png"; import MakeLogo from "@/images/make-small.png"; import n8nLogo from "@/images/n8n.png"; +import notionLogo from "@/images/notion.png"; import WebhookLogo from "@/images/webhook.png"; import ZapierLogo from "@/images/zapier-small.png"; import { getServerSession } from "next-auth"; @@ -13,6 +14,7 @@ import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service" import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getTeamByEnvironmentId } from "@formbricks/lib/team/service"; import { getWebhookCountBySource } from "@formbricks/lib/webhook/service"; +import { TIntegrationType } from "@formbricks/types/integration"; import { Card } from "@formbricks/ui/Card"; import { ErrorComponent } from "@formbricks/ui/ErrorComponent"; @@ -42,6 +44,8 @@ export default async function IntegrationsPage({ params }) { getWebhookCountBySource(environmentId, "n8n"), ]); + const isIntegrationConnected = (type: TIntegrationType) => + integrations.some((integration) => integration.type === type); if (!session) { throw new Error("Session not found"); } @@ -53,11 +57,10 @@ export default async function IntegrationsPage({ params }) { const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id); const { isViewer } = getAccessFlags(currentUserMembership?.role); - const containsGoogleSheetIntegration = integrations.some( - (integration) => integration.type === "googleSheets" - ); - - const containsAirtableIntegration = integrations.some((integration) => integration.type === "airtable"); + const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets"); + const isNotionIntegrationConnected = isIntegrationConnected("notion"); + const isAirtableIntegrationConnected = isIntegrationConnected("airtable"); + const isN8nIntegrationConnected = isIntegrationConnected("n8n"); const integrationCards = [ { @@ -108,7 +111,7 @@ export default async function IntegrationsPage({ params }) { }, { connectHref: `/environments/${params.environmentId}/integrations/google-sheets`, - connectText: `${containsGoogleSheetIntegration ? "Manage Sheets" : "Connect"}`, + connectText: `${isGoogleSheetsIntegrationConnected ? "Manage Sheets" : "Connect"}`, connectNewTab: false, docsHref: "https://formbricks.com/docs/integrations/google-sheets", docsText: "Docs", @@ -116,12 +119,12 @@ export default async function IntegrationsPage({ params }) { label: "Google Sheets", description: "Instantly populate your spreadsheets with survey data", icon: Google sheets Logo, - connected: containsGoogleSheetIntegration ? true : false, - statusText: containsGoogleSheetIntegration ? "Connected" : "Not Connected", + connected: isGoogleSheetsIntegrationConnected, + statusText: isGoogleSheetsIntegrationConnected ? "Connected" : "Not Connected", }, { connectHref: `/environments/${params.environmentId}/integrations/airtable`, - connectText: `${containsAirtableIntegration ? "Manage Table" : "Connect"}`, + connectText: `${isAirtableIntegrationConnected ? "Manage Table" : "Connect"}`, connectNewTab: false, docsHref: "https://formbricks.com/docs/integrations/airtable", docsText: "Docs", @@ -129,15 +132,15 @@ export default async function IntegrationsPage({ params }) { label: "Airtable", description: "Instantly populate your airtable table with survey data", icon: Airtable Logo, - connected: containsAirtableIntegration ? true : false, - statusText: containsAirtableIntegration ? "Connected" : "Not Connected", + connected: isAirtableIntegrationConnected, + statusText: isAirtableIntegrationConnected ? "Connected" : "Not Connected", }, { docsHref: "https://formbricks.com/docs/integrations/n8n", + connectText: `${isN8nIntegrationConnected ? "Manage" : "Connect"}`, docsText: "Docs", docsNewTab: true, connectHref: "https://n8n.io", - connectText: "Connect", connectNewTab: true, label: "n8n", description: "Integrate Formbricks with 350+ apps via n8n", @@ -168,6 +171,19 @@ export default async function IntegrationsPage({ params }) { ? "Not Connected" : `${makeWebhookCount} integration`, }, + { + connectHref: `/environments/${params.environmentId}/integrations/notion`, + connectText: `${isNotionIntegrationConnected ? "Manage" : "Connect"}`, + connectNewTab: false, + docsHref: "https://formbricks.com/docs/integrations/notion", + docsText: "Docs", + docsNewTab: true, + label: "Notion", + description: "Send data to your Notion database", + icon: Notion Logo, + connected: isNotionIntegrationConnected, + statusText: isNotionIntegrationConnected ? "Connected" : "Not Connected", + }, ]; if (isViewer) return ; diff --git a/apps/web/app/api/pipeline/lib/handleIntegrations.ts b/apps/web/app/api/pipeline/lib/handleIntegrations.ts index 46fbe560e8..187c55a4a0 100644 --- a/apps/web/app/api/pipeline/lib/handleIntegrations.ts +++ b/apps/web/app/api/pipeline/lib/handleIntegrations.ts @@ -1,12 +1,19 @@ import { writeData as airtableWriteData } from "@formbricks/lib/airtable/service"; import { writeData } from "@formbricks/lib/googleSheet/service"; +import { writeData as writeNotionData } from "@formbricks/lib/notion/service"; import { getSurvey } from "@formbricks/lib/survey/service"; import { TIntegration } from "@formbricks/types/integration"; import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; import { TIntegrationGoogleSheets } from "@formbricks/types/integration/googleSheet"; +import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion"; import { TPipelineInput } from "@formbricks/types/pipelines"; +import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys"; -export async function handleIntegrations(integrations: TIntegration[], data: TPipelineInput) { +export async function handleIntegrations( + integrations: TIntegration[], + data: TPipelineInput, + surveyData: TSurvey +) { for (const integration of integrations) { switch (integration.type) { case "googleSheets": @@ -15,6 +22,9 @@ export async function handleIntegrations(integrations: TIntegration[], data: TPi case "airtable": await handleAirtableIntegration(integration as TIntegrationAirtable, data); break; + case "notion": + await handleNotionIntegration(integration as TIntegrationNotion, data, surveyData); + break; } } } @@ -23,7 +33,7 @@ async function handleAirtableIntegration(integration: TIntegrationAirtable, data if (integration.config.data.length > 0) { for (const element of integration.config.data) { if (element.surveyId === data.surveyId) { - const values = await extractResponses(data, element.questionIds); + const values = await extractResponses(data, element.questionIds as string[]); await airtableWriteData(integration.config.key, element, values); } @@ -35,7 +45,7 @@ async function handleGoogleSheetsIntegration(integration: TIntegrationGoogleShee if (integration.config.data.length > 0) { for (const element of integration.config.data) { if (element.surveyId === data.surveyId) { - const values = await extractResponses(data, element.questionIds); + const values = await extractResponses(data, element.questionIds as string[]); await writeData(integration.config.key, element.spreadsheetId, values); } } @@ -62,3 +72,103 @@ async function extractResponses(data: TPipelineInput, questionIds: string[]): Pr return [responses, questions]; } + +async function handleNotionIntegration( + integration: TIntegrationNotion, + data: TPipelineInput, + surveyData: TSurvey +) { + if (integration.config.data.length > 0) { + for (const element of integration.config.data) { + if (element.surveyId === data.surveyId) { + const properties = buildNotionPayloadProperties(element.mapping, data, surveyData); + await writeNotionData(element.databaseId, properties, integration.config); + } + } + } +} + +function buildNotionPayloadProperties( + mapping: TIntegrationNotionConfigData["mapping"], + data: TPipelineInput, + surveyData: TSurvey +) { + const properties: any = {}; + const responses = data.response.data; + + const mappingQIds = mapping + .filter((m) => m.question.type === TSurveyQuestionType.PictureSelection) + .map((m) => m.question.id); + + Object.keys(responses).forEach((resp) => { + if (mappingQIds.find((qId) => qId === resp)) { + const selectedChoiceIds = responses[resp] as string[]; + const pictureQuestion = surveyData.questions.find((q) => q.id === resp); + + responses[resp] = (pictureQuestion as any)?.choices + .filter((choice) => selectedChoiceIds.includes(choice.id)) + .map((choice) => choice.imageUrl); + } + }); + + mapping.forEach((map) => { + const value = responses[map.question.id]; + + properties[map.column.name] = { + [map.column.type]: getValue(map.column.type, value), + }; + }); + + return properties; +} + +// notion requires specific payload for each column type +// * TYPES NOT SUPPORTED BY NOTION API - rollup, created_by, created_time, last_edited_by, or last_edited_time +function getValue(colType: string, value: string | string[] | number) { + try { + switch (colType) { + case "select": + return { + name: value, + }; + case "multi_select": + return (value as []).map((v: string) => ({ name: v })); + case "title": + return [ + { + text: { + content: value, + }, + }, + ]; + case "rich_text": + return [ + { + text: { + content: value, + }, + }, + ]; + case "status": + return { + name: value, + }; + case "checkbox": + return value === "accepted" || value === "clicked"; + case "date": + return { + start: new Date(value as string).toISOString().substring(0, 10), + }; + case "email": + return value; + case "number": + return parseInt(value as string); + case "phone_number": + return value; + case "url": + return typeof value === "string" ? value : (value as string[]).join(", "); + } + } catch (error) { + throw new Error("Payload build failed!"); + } +} diff --git a/apps/web/app/api/pipeline/route.ts b/apps/web/app/api/pipeline/route.ts index c6d73dbd5c..30c2499910 100644 --- a/apps/web/app/api/pipeline/route.ts +++ b/apps/web/app/api/pipeline/route.ts @@ -99,9 +99,22 @@ export async function POST(request: Request) { }, }); + let surveyData; + const integrations = await getIntegrations(environmentId); + if (integrations.length > 0) { - handleIntegrations(integrations, inputValidation.data); + surveyData = await prisma.survey.findUnique({ + where: { + id: surveyId, + }, + select: { + id: true, + name: true, + questions: true, + }, + }); + handleIntegrations(integrations, inputValidation.data, surveyData); } // filter all users that have email notifications enabled for this survey const usersWithNotifications = users.filter((user) => { @@ -114,16 +127,19 @@ export async function POST(request: Request) { if (usersWithNotifications.length > 0) { // get survey - const surveyData = await prisma.survey.findUnique({ - where: { - id: surveyId, - }, - select: { - id: true, - name: true, - questions: true, - }, - }); + if (!surveyData) { + surveyData = await prisma.survey.findUnique({ + where: { + id: surveyId, + }, + select: { + id: true, + name: true, + questions: true, + }, + }); + } + if (!surveyData) { console.error(`Pipeline: Survey with id ${surveyId} not found`); return new Response("Survey not found", { diff --git a/apps/web/app/api/v1/integrations/notion/callback/route.ts b/apps/web/app/api/v1/integrations/notion/callback/route.ts new file mode 100644 index 0000000000..31c4fc6426 --- /dev/null +++ b/apps/web/app/api/v1/integrations/notion/callback/route.ts @@ -0,0 +1,76 @@ +import { responses } from "@/app/lib/api/response"; +import { NextRequest, NextResponse } from "next/server"; + +import { + ENCRYPTION_KEY, + NOTION_OAUTH_CLIENT_ID, + NOTION_OAUTH_CLIENT_SECRET, + NOTION_REDIRECT_URI, + WEBAPP_URL, +} from "@formbricks/lib/constants"; +import { symmetricEncrypt } from "@formbricks/lib/crypto"; +import { createOrUpdateIntegration } from "@formbricks/lib/integration/service"; + +export async function GET(req: NextRequest) { + const url = req.url; + const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters + const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter + const code = queryParams.get("code"); + const error = queryParams.get("error"); + + if (!environmentId) { + return responses.badRequestResponse("Invalid environmentId"); + } + + if (code && typeof code !== "string") { + return responses.badRequestResponse("`code` must be a string"); + } + + const client_id = NOTION_OAUTH_CLIENT_ID; + const client_secret = NOTION_OAUTH_CLIENT_SECRET; + const redirect_uri = NOTION_REDIRECT_URI; + if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing"); + if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing"); + if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing"); + if (code) { + // encode in base 64 + const encoded = Buffer.from(`${client_id}:${client_secret}`).toString("base64"); + + const response = await fetch("https://api.notion.com/v1/oauth/token", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Basic ${encoded}`, + }, + body: JSON.stringify({ + grant_type: "authorization_code", + code: code, + redirect_uri: redirect_uri, + }), + }); + + const tokenData = await response.json(); + const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY!); + tokenData.access_token = encryptedAccessToken; + + const notionIntegration = { + type: "notion" as "notion", + environment: environmentId, + config: { + key: tokenData, + data: [], + }, + }; + + const result = await createOrUpdateIntegration(environmentId, notionIntegration); + + if (result) { + return NextResponse.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`); + } + } else if (error) { + return NextResponse.redirect( + `${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}` + ); + } +} diff --git a/apps/web/app/api/v1/integrations/notion/route.ts b/apps/web/app/api/v1/integrations/notion/route.ts new file mode 100644 index 0000000000..c8af1c9e88 --- /dev/null +++ b/apps/web/app/api/v1/integrations/notion/route.ts @@ -0,0 +1,41 @@ +import { responses } from "@/app/lib/api/response"; +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; + +import { authOptions } from "@formbricks/lib/authOptions"; +import { + NOTION_AUTH_URL, + NOTION_OAUTH_CLIENT_ID, + NOTION_OAUTH_CLIENT_SECRET, + NOTION_REDIRECT_URI, +} from "@formbricks/lib/constants"; +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; + +export async function GET(req: NextRequest) { + const environmentId = req.headers.get("environmentId"); + const session = await getServerSession(authOptions); + + if (!environmentId) { + return responses.badRequestResponse("environmentId is missing"); + } + + if (!session) { + return responses.notAuthenticatedResponse(); + } + + const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId); + if (!canUserAccessEnvironment) { + return responses.unauthorizedResponse(); + } + + const client_id = NOTION_OAUTH_CLIENT_ID; + const client_secret = NOTION_OAUTH_CLIENT_SECRET; + const auth_url = NOTION_AUTH_URL; + const redirect_uri = NOTION_REDIRECT_URI; + if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing"); + if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing"); + if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing"); + if (!auth_url) return responses.internalServerErrorResponse("Notion auth url is missing"); + + return responses.successResponse({ authUrl: `${auth_url}&state=${environmentId}` }); +} diff --git a/apps/web/images/notion.png b/apps/web/images/notion.png new file mode 100644 index 0000000000..391051679c Binary files /dev/null and b/apps/web/images/notion.png differ diff --git a/packages/database/migrations/20231207064643_add_notion_integration/migration.sql b/packages/database/migrations/20231207064643_add_notion_integration/migration.sql new file mode 100644 index 0000000000..c624d507f4 --- /dev/null +++ b/packages/database/migrations/20231207064643_add_notion_integration/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "IntegrationType" ADD VALUE 'notion'; diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index a7a1099da2..4a161743d9 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -350,6 +350,7 @@ enum EnvironmentType { enum IntegrationType { googleSheets + notion airtable } diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 131b9e0846..b2a31594d5 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -44,6 +44,11 @@ export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID; export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET; export const GOOGLE_SHEETS_REDIRECT_URL = env.GOOGLE_SHEETS_REDIRECT_URL; +export const NOTION_OAUTH_CLIENT_ID = env.NOTION_OAUTH_CLIENT_ID; +export const NOTION_OAUTH_CLIENT_SECRET = env.NOTION_OAUTH_CLIENT_SECRET; +export const NOTION_REDIRECT_URI = `${WEBAPP_URL}/api/v1/integrations/notion/callback`; +export const NOTION_AUTH_URL = `https://api.notion.com/v1/oauth/authorize?client_id=${env.NOTION_OAUTH_CLIENT_ID}&response_type=code&owner=user&redirect_uri=${NOTION_REDIRECT_URI}`; + export const AIRTABLE_CLIENT_ID = env.AIRTABLE_CLIENT_ID; export const SMTP_HOST = env.SMTP_HOST; diff --git a/packages/lib/env.mjs b/packages/lib/env.mjs index 67cd52eec7..8e18b90199 100644 --- a/packages/lib/env.mjs +++ b/packages/lib/env.mjs @@ -61,6 +61,8 @@ export const env = createEnv({ S3_SECRET_KEY: z.string().optional(), S3_REGION: z.string().optional(), S3_BUCKET_NAME: z.string().optional(), + NOTION_OAUTH_CLIENT_ID: z.string().optional(), + NOTION_OAUTH_CLIENT_SECRET: z.string().optional(), AZUREAD_CLIENT_SECRET: z.string().optional(), AZUREAD_TENANT_ID: z.string().optional(), AZUREAD_CLIENT_ID: z.string().optional(), @@ -128,6 +130,8 @@ export const env = createEnv({ S3_SECRET_KEY: process.env.S3_SECRET_KEY, S3_REGION: process.env.S3_REGION, S3_BUCKET_NAME: process.env.S3_BUCKET_NAME, + NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID, + NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET, NEXT_PUBLIC_FORMBRICKS_API_HOST: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST, NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID, NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID, diff --git a/packages/lib/integration/service.ts b/packages/lib/integration/service.ts index 10c7c061e5..9d97e8654a 100644 --- a/packages/lib/integration/service.ts +++ b/packages/lib/integration/service.ts @@ -45,6 +45,7 @@ export async function createOrUpdateIntegration( integrationCache.revalidate({ environmentId, + type: integrationData.type, }); return integration; } catch (error) { diff --git a/packages/lib/notion/service.ts b/packages/lib/notion/service.ts new file mode 100644 index 0000000000..a09921efbf --- /dev/null +++ b/packages/lib/notion/service.ts @@ -0,0 +1,72 @@ +import { + TIntegrationNotion, + TIntegrationNotionConfig, + TIntegrationNotionDatabase, +} from "@formbricks/types/integration/notion"; + +import { ENCRYPTION_KEY } from "../constants"; +import { symmetricDecrypt } from "../crypto"; +import { getIntegrationByType } from "../integration/service"; + +async function fetchPages(config: TIntegrationNotionConfig) { + try { + const res = await fetch("https://api.notion.com/v1/search", { + headers: getHeaders(config), + method: "POST", + body: JSON.stringify({ + page_size: 100, + filter: { + value: "database", + property: "object", + }, + }), + }); + return (await res.json()).results; + } catch (error) { + throw error; + } +} + +export const getNotionDatabases = async (environmentId: string): Promise => { + let results: TIntegrationNotionDatabase[] = []; + try { + const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion; + if (notionIntegration && notionIntegration.config?.key.bot_id) { + results = await fetchPages(notionIntegration.config); + } + return results; + } catch (error) { + throw error; + } +}; + +export async function writeData( + databaseId: string, + properties: Record, + config: TIntegrationNotionConfig +) { + try { + await fetch(`https://api.notion.com/v1/pages`, { + headers: getHeaders(config), + method: "POST", + body: JSON.stringify({ + parent: { + database_id: databaseId, + }, + properties: properties, + }), + }); + } catch (error) { + throw error; + } +} + +function getHeaders(config: TIntegrationNotionConfig) { + const decryptedToken = symmetricDecrypt(config.key.access_token, ENCRYPTION_KEY!); + return { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${decryptedToken}`, + "Notion-Version": "2022-06-28", + }; +} diff --git a/packages/types/integration/index.ts b/packages/types/integration/index.ts index baeda6d0f0..5650235753 100644 --- a/packages/types/integration/index.ts +++ b/packages/types/integration/index.ts @@ -2,12 +2,18 @@ import { z } from "zod"; import { ZIntegrationAirtableConfig, ZIntegrationAirtableInput } from "./airtable"; import { ZIntegrationGoogleSheetsConfig, ZIntegrationGoogleSheetsInput } from "./googleSheet"; +import { ZIntegrationNotionConfig, ZIntegrationNotionInput } from "./notion"; export * from "./sharedTypes"; -export const ZIntegrationType = z.enum(["googleSheets", "airtable"]); +export const ZIntegrationType = z.enum(["googleSheets", "n8n", "airtable", "notion"]); +export type TIntegrationType = z.infer; -export const ZIntegrationConfig = z.union([ZIntegrationGoogleSheetsConfig, ZIntegrationAirtableConfig]); +export const ZIntegrationConfig = z.union([ + ZIntegrationGoogleSheetsConfig, + ZIntegrationAirtableConfig, + ZIntegrationNotionConfig, +]); export type TIntegrationConfig = z.infer; @@ -31,7 +37,11 @@ export const ZIntegrationBaseSurveyData = z.object({ surveyName: z.string(), }); -export const ZIntegrationInput = z.union([ZIntegrationGoogleSheetsInput, ZIntegrationAirtableInput]); +export const ZIntegrationInput = z.union([ + ZIntegrationGoogleSheetsInput, + ZIntegrationAirtableInput, + ZIntegrationNotionInput, +]); export type TIntegrationInput = z.infer; export const ZIntegrationItem = z.object({ diff --git a/packages/types/integration/notion.ts b/packages/types/integration/notion.ts new file mode 100644 index 0000000000..9e41bdfc61 --- /dev/null +++ b/packages/types/integration/notion.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; + +import { ZIntegrationBase, ZIntegrationBaseSurveyData } from "./sharedTypes"; + +export const ZIntegrationNotionCredential = z.object({ + access_token: z.string(), + bot_id: z.string(), + token_type: z.string(), + duplicated_template_id: z.string().nullable(), + owner: z.object({ + type: z.string(), + workspace: z.boolean().nullable(), + user: z + .object({ + id: z.string(), + name: z.string(), + type: z.string(), + object: z.string(), + person: z.object({ + email: z.string(), + }), + avatar_url: z.string(), + }) + .nullable(), + }), + workspace_icon: z.string().nullable(), + workspace_id: z.string(), + workspace_name: z.string().nullable(), +}); + +export type TIntegrationNotionCredential = z.infer; + +export const ZIntegrationNotionConfigData = z + .object({ + // question -> notion database column mapping + mapping: z.array( + z.object({ + question: z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + }), + column: z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + }), + }) + ), + databaseId: z.string(), + databaseName: z.string(), + }) + .merge( + ZIntegrationBaseSurveyData.omit({ + questionIds: true, + questions: true, + }) + ); + +export type TIntegrationNotionConfigData = z.infer; + +export const ZIntegrationNotionConfig = z.object({ + key: ZIntegrationNotionCredential, + data: z.array(ZIntegrationNotionConfigData), +}); + +export type TIntegrationNotionConfig = z.infer; + +export const ZIntegrationNotion = ZIntegrationBase.extend({ + type: z.literal("notion"), + config: ZIntegrationNotionConfig, +}); + +export type TIntegrationNotion = z.infer; + +export const ZIntegrationNotionInput = z.object({ + type: z.literal("notion"), + config: ZIntegrationNotionConfig, +}); + +export type TIntegrationNotionInput = z.infer; + +export const ZIntegrationNotionDatabase = z.object({ + id: z.string(), + name: z.string(), + properties: z.object({}), +}); + +export type TIntegrationNotionDatabase = z.infer; diff --git a/packages/ui/Modal/index.tsx b/packages/ui/Modal/index.tsx index faddd74caf..b10a7c1775 100644 --- a/packages/ui/Modal/index.tsx +++ b/packages/ui/Modal/index.tsx @@ -15,6 +15,7 @@ type Modal = { noPadding?: boolean; blur?: boolean; closeOnOutsideClick?: boolean; + size?: "md" | "lg"; }; export const Modal: React.FC = ({ @@ -25,7 +26,13 @@ export const Modal: React.FC = ({ noPadding, blur = true, closeOnOutsideClick = true, + size = "md", }) => { + const sizeClassName = { + md: "sm:w-full sm:max-w-xl", + lg: "sm:w-[820px] sm:max-w-full", + }; + return ( <> @@ -58,8 +65,9 @@ export const Modal: React.FC = ({ leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">