feat: Notion Integration (#1197)

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Pratik
2023-12-14 18:41:23 +05:30
committed by GitHub
parent 95ed9b87de
commit 8fd78bc08f
36 changed files with 1652 additions and 37 deletions

View File

@@ -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=

View File

@@ -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
</Note>
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.
<Image
src={LinkSurveyWithSheet}
@@ -71,17 +72,16 @@ Before the next step, make sure that you have a Formbricks Survey with at least
className="max-w-full rounded-lg sm:max-w-3xl"
/>
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.
<Image
src={LinkWithQuestions}
alt="Select question to link with Google Sheet"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
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.
<Image
src={ListLinkedSurveys}

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

View File

@@ -0,0 +1,126 @@
import IntegrationsTab from "./images/integrations-tab.png";
import ConnectWithNotion from "./images/connect-with-notion.png";
import NotionConnected from "./images/notion-connected.png";
import LinkSurveyWithDatabase from "./images/link-survey-with-database.png";
import LinkWithDatabases from "./images/link-with-databases.png";
import ListLinkedDatabases from "./images/list-linked-databases.png";
import DeleteConnection from "./images/delete-connection.png";
import Image from "next/image";
export const metadata = {
title: "Notion",
description:
"The notion integration allows you to automatically send responses to a Notion database of your choice.",
};
#### Integrations
# Notion
The notion integration allows you to automatically send responses to a Notion database of your choice.
<Note>
This feature is enabled by default in Formbricks Cloud but needs to be self-configured when running a
self-hosted version of Formbricks.
</Note>
## 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.
<Image
src={IntegrationsTab}
alt="Formbricks Integrations Tab"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
2. Now click on the "Connect with Notion" button to authenticate yourself with Notion.
<Image
src={ConnectWithNotion}
alt="Connect Formbricks with your Notion account"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
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:
<Image
src={NotionConnected}
alt="Formbricks is now connected with Notion"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
<Note>
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.
</Note>
5. Now click on the "Link New Database" button to link a new Notion database with Formbricks and a modal will open up.
<Image
src={LinkSurveyWithDatabase}
alt="Link Formbricks with a Notion database"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
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.
<Image
src={LinkWithDatabases}
alt="Question to notion database column mapping"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
7. On submitting, the modal will close and you will see the linked Notion database in the list of linked Notion databases.
<Image
src={ListLinkedDatabases}
alt="List of linked notion databases"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
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://<your-public-facing-url>/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 `<your-workspace-name-here`> 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.
<Image
src={DeleteConnection}
alt="Delete Notion Integration with Formbricks"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
Still struggling or something not working as expected? [Join our Discord!](https://formbricks.com/discord) and we'd be glad to assist you!

View File

@@ -237,6 +237,7 @@ export const navigation: Array<NavGroup> = [
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" },

View File

@@ -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);
}

View File

@@ -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<React.SetStateAction<boolean>>;
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<TIntegrationNotionDatabase | null>();
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(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<any>(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 (
<>
- <i>{col.name}</i> of type <b>{col.type}</b> is not supported by notion API. The data
won&apos;t be reflected in your notion database.
</>
);
case ERRORS.MAPPING:
return (
<>
- <i>&quot;{ques.name}&quot;</i> of type{" "}
<b>{questionTypes.find((qt) => qt.id === ques.type)?.label}</b> can&apos;t be mapped to the
column <i>&quot;{col.name}&quot;</i> of type <b>{col.type}</b>
</>
);
default:
return null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error]);
if (!error) return null;
return (
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
<span className="mb-2 block">{error.type}</span>
{showErrorMsg}
</div>
);
};
const getFilteredDbItems = () => {
const colMapping = mapping.map((m) => m.column.id);
return dbItems.filter((item) => !colMapping.includes(item.id));
};
return (
<div className="w-full">
<ErrorMsg
key={idx}
error={mapping[idx]?.error}
col={mapping[idx].column}
ques={mapping[idx].question}
/>
<div className="flex w-full items-center gap-3">
<div className="flex w-full items-center">
<div className="w-[340px] max-w-full">
<DropdownSelector
placeholder="Select a survey question"
items={filteredQuestionItems}
selectedItem={mapping?.[idx]?.question}
setSelectedItem={(item) => {
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}
/>
</div>
<div className="h-px w-4 border-t border-t-slate-300" />
<div className="w-[340px] max-w-full">
<DropdownSelector
placeholder="Select a field to map"
items={getFilteredDbItems()}
selectedItem={mapping?.[idx]?.column}
setSelectedItem={(item) => {
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}
/>
</div>
</div>
<button
type="button"
className={`rounded-md p-1 hover:bg-slate-300 ${
idx === mapping.length - 1 ? "visible" : "invisible"
}`}
onClick={addRow}>
<PlusIcon className="h-5 w-5 font-bold text-gray-500" />
</button>
<button
type="button"
className={`flex-1 rounded-md p-1 hover:bg-red-100 ${
mapping.length > 1 ? "visible" : "invisible"
}`}
onClick={deleteRow}>
<XMarkIcon className="h-5 w-5 text-red-500" />
</button>
</div>
</div>
);
};
return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} size="lg">
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image className="w-12" src={NotionLogo} alt="Google Sheet logo" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Link Notion Database</div>
<div className="text-sm text-slate-500">Sync responses with a Notion Database</div>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(linkDatabase)} className="w-full">
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div>
<div className="mb-4">
<DropdownSelector
label="Select Database"
items={databases.map((d) => ({
id: d.id,
name: (d as any).title?.[0]?.plain_text,
properties: d.properties,
}))}
selectedItem={selectedDatabase}
setSelectedItem={setSelectedDatabase}
disabled={databases.length === 0}
/>
{selectedDatabase && hasMatchingId && (
<p className="text-xs text-amber-700">
<strong>Warning:</strong> A connection with this database is live. Please make changes
with caution.
</p>
)}
<p className="m-1 text-xs text-slate-500">
{databases.length === 0 &&
"You have to create at least one database to be able to setup this integration"}
</p>
</div>
<div className="mb-4">
<DropdownSelector
label="Select Survey"
items={surveys}
selectedItem={selectedSurvey}
setSelectedItem={setSelectedSurvey}
disabled={surveys.length === 0}
/>
<p className="m-1 text-xs text-slate-500">
{surveys.length === 0 &&
"You have to create a survey to be able to setup this integration"}
</p>
</div>
{selectedDatabase && selectedSurvey && (
<div>
<Label>Map Formbricks fields to Notion property</Label>
<div className="mt-4">
{mapping.map((_, idx) => (
<MappingRow idx={idx} key={idx} />
))}
</div>
</div>
)}
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button
type="button"
variant="warn"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
Delete
</Button>
) : (
<Button
type="button"
variant="minimal"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
Cancel
</Button>
)}
<Button
variant="darkCTA"
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration ? "Update" : "Link Database"}
</Button>
</div>
</div>
</form>
</div>
</Modal>
);
}
interface DropdownSelectorProps {
label?: string;
items: Array<any>;
selectedItem: any;
setSelectedItem: React.Dispatch<React.SetStateAction<any>>;
disabled: boolean;
placeholder?: string;
refetch?: () => void;
}
const DropdownSelector = ({
label,
items,
selectedItem,
setSelectedItem,
disabled,
placeholder,
refetch,
}: DropdownSelectorProps) => {
return (
<div className="col-span-1">
{label && <Label htmlFor={label}>{label}</Label>}
<div className="mt-1 flex items-center gap-3">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
disabled={disabled ? disabled : false}
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">
{selectedItem ? selectedItem.name || placeholder || label : `${placeholder || label}`}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-gray-500" />
</span>
</button>
</DropdownMenu.Trigger>
{!disabled && (
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-50 max-h-64 min-w-[220px] overflow-auto rounded-md bg-white text-sm text-slate-800 shadow-md"
align="start">
{items &&
items.map((item) => (
<DropdownMenu.Item
key={item.id}
className="flex cursor-pointer items-center p-3 hover:bg-gray-100 hover:outline-none data-[disabled]:cursor-default data-[disabled]:opacity-50"
onSelect={() => setSelectedItem(item)}>
{item.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
)}
</DropdownMenu.Root>
{refetch && (
<button
type="button"
className="rounded-md p-1 hover:bg-slate-300"
onClick={() => {
refetch();
}}>
<ArrowPathIcon className="h-5 w-5 font-bold text-gray-500" />
</button>
)}
</div>
</div>
);
};

View File

@@ -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 (
<div className="flex h-[75vh] w-full items-center justify-center">
<div className="flex w-1/2 flex-col items-center justify-center rounded-lg bg-white p-8 shadow">
<div className="flex w-1/2 justify-center -space-x-4">
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-4 shadow-md">
<Image className="w-1/2" src={FormbricksLogo} alt="Formbricks Logo" />
</div>
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-4 shadow-md">
<Image className="w-1/2" src={NotionLogo} alt="Google Sheet logo" />
</div>
</div>
<p className="my-8">Sync responses directly with your Notion database.</p>
{!enabled && (
<p className="mb-8 rounded border-gray-200 bg-gray-100 p-3 text-sm">
Notion Integration is not configured in your instance of Formbricks.
<br />
Please follow the{" "}
<Link href="https://formbricks.com/docs/integrations/google-sheets" className="underline">
docs
</Link>{" "}
to configure it.
</p>
)}
<Button variant="darkCTA" loading={isConnecting} onClick={handleAuthorizeNotion} disabled={!enabled}>
Connect with Notion
</Button>
</div>
</div>
);
}

View File

@@ -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<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
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 (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span
className="cursor-pointer text-slate-500"
onClick={() => {
setIsDeleteIntegrationModalOpen(true);
}}>
Connected with {notionIntegration.config.key.workspace_name} workspace
</span>
</div>
<Button
variant="darkCTA"
onClick={() => {
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
}}>
Link new database
</Button>
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage="Your Notion integrations will appear here as soon as you add them. ⏲️"
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
<div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 hidden text-center sm:block">Survey</div>
<div className="col-span-2 hidden text-center sm:block">Database Name</div>
<div className="col-span-2 hidden text-center sm:block">Updated At</div>
</div>
{integrationArray &&
integrationArray.map((data, index) => {
return (
<div
key={index}
className="m-2 grid h-16 cursor-pointer grid-cols-6 content-center rounded-lg hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.databaseName}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString())}</div>
</div>
);
})}
</div>
</div>
)}
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat="Notion Connection"
onDelete={handleDeleteIntegration}
text="Are you sure? Your integrations will break."
isDeleting={isDeleting}
/>
</div>
);
}

View File

@@ -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 ? (
<>
<AddIntegrationModal
environmentId={environment.id}
surveys={surveys}
open={isModalOpen}
setOpen={setModalOpen}
notionIntegration={notionIntegration}
databases={databasesArray}
selectedIntegration={selectedIntegration}
/>
<Home
environment={environment}
notionIntegration={notionIntegration}
setOpenAddIntegrationModal={setModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
/>
</>
) : (
<Connect enabled={enabled} environmentId={environment.id} webAppUrl={webAppUrl} />
)}
</>
);
}

View File

@@ -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",
};

View File

@@ -0,0 +1,14 @@
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
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;
};

View File

@@ -0,0 +1,59 @@
import { Button } from "@formbricks/ui/Button";
import GoBackButton from "@formbricks/ui/GoBackButton";
export default function Loading() {
return (
<div className="mt-6 p-6">
<GoBackButton />
<div className="mb-6 text-right">
<Button
variant="darkCTA"
className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-gray-200">
Link new database
</Button>
</div>
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 text-center ">Survey</div>
<div className="col-span-2 text-center">Database Name</div>
<div className="col-span-2 text-center">Updated At</div>
</div>
<div className="grid-cols-7">
{[...Array(3)].map((_, index) => (
<div
key={index}
className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="text-left">
<div className="font-medium text-slate-900">
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
</div>
<div className="col-span-1 my-auto flex items-center justify-center text-center text-sm text-slate-500">
<div className="font-medium text-slate-500">
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
<div className="col-span-4 my-auto flex items-center justify-center text-center text-sm text-slate-500">
<div className="font-medium text-slate-500">
<div className="mt-0 h-4 w-36 animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm text-slate-500">
<div className="font-medium text-slate-500">
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-16 animate-pulse rounded-full bg-gray-200"></div>
</div>
<div className="text-center"></div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -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 (
<>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
<NotionWrapper
enabled={enabled}
surveys={surveys}
environment={environment}
notionIntegration={notionIntegration as TIntegrationNotion}
webAppUrl={WEBAPP_URL}
databasesArray={databasesArray}
/>
</>
);
}

View File

@@ -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: <Image src={GoogleSheetsLogo} alt="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: <Image src={AirtableLogo} alt="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: <Image src={notionLogo} alt="Notion Logo" />,
connected: isNotionIntegrationConnected,
statusText: isNotionIntegrationConnected ? "Connected" : "Not Connected",
},
];
if (isViewer) return <ErrorComponent />;

View File

@@ -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!");
}
}

View File

@@ -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", {

View File

@@ -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}`
);
}
}

View File

@@ -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}` });
}

BIN
apps/web/images/notion.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "IntegrationType" ADD VALUE 'notion';

View File

@@ -350,6 +350,7 @@ enum EnvironmentType {
enum IntegrationType {
googleSheets
notion
airtable
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -45,6 +45,7 @@ export async function createOrUpdateIntegration(
integrationCache.revalidate({
environmentId,
type: integrationData.type,
});
return integration;
} catch (error) {

View File

@@ -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<TIntegrationNotionDatabase[]> => {
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<string, Object>,
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",
};
}

View File

@@ -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<typeof ZIntegrationType>;
export const ZIntegrationConfig = z.union([ZIntegrationGoogleSheetsConfig, ZIntegrationAirtableConfig]);
export const ZIntegrationConfig = z.union([
ZIntegrationGoogleSheetsConfig,
ZIntegrationAirtableConfig,
ZIntegrationNotionConfig,
]);
export type TIntegrationConfig = z.infer<typeof ZIntegrationConfig>;
@@ -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<typeof ZIntegrationInput>;
export const ZIntegrationItem = z.object({

View File

@@ -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<typeof ZIntegrationNotionCredential>;
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<typeof ZIntegrationNotionConfigData>;
export const ZIntegrationNotionConfig = z.object({
key: ZIntegrationNotionCredential,
data: z.array(ZIntegrationNotionConfigData),
});
export type TIntegrationNotionConfig = z.infer<typeof ZIntegrationNotionConfig>;
export const ZIntegrationNotion = ZIntegrationBase.extend({
type: z.literal("notion"),
config: ZIntegrationNotionConfig,
});
export type TIntegrationNotion = z.infer<typeof ZIntegrationNotion>;
export const ZIntegrationNotionInput = z.object({
type: z.literal("notion"),
config: ZIntegrationNotionConfig,
});
export type TIntegrationNotionInput = z.infer<typeof ZIntegrationNotionInput>;
export const ZIntegrationNotionDatabase = z.object({
id: z.string(),
name: z.string(),
properties: z.object({}),
});
export type TIntegrationNotionDatabase = z.infer<typeof ZIntegrationNotionDatabase>;

View File

@@ -15,6 +15,7 @@ type Modal = {
noPadding?: boolean;
blur?: boolean;
closeOnOutsideClick?: boolean;
size?: "md" | "lg";
};
export const Modal: React.FC<Modal> = ({
@@ -25,7 +26,13 @@ export const Modal: React.FC<Modal> = ({
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 (
<>
<Transition.Root show={open} as={Fragment}>
@@ -58,8 +65,9 @@ export const Modal: React.FC<Modal> = ({
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel
className={clsx(
"relative transform rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-xl ",
`${noPadding ? "" : "px-4 pb-4 pt-5 sm:p-6"}`
"relative transform rounded-lg bg-white text-left shadow-xl transition-all sm:my-8",
`${noPadding ? "" : "px-4 pb-4 pt-5 sm:p-6"}`,
sizeClassName[size]
)}>
<div className="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
<button

View File

@@ -70,6 +70,8 @@
"GOOGLE_SHEETS_CLIENT_ID",
"GOOGLE_SHEETS_CLIENT_SECRET",
"GOOGLE_SHEETS_REDIRECT_URL",
"NOTION_OAUTH_CLIENT_ID",
"NOTION_OAUTH_CLIENT_SECRET",
"HEROKU_APP_NAME",
"IMPRINT_URL",
"INSTANCE_ID",