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>
@@ -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=
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
After Width: | Height: | Size: 653 KiB |
|
After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 836 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 462 KiB |
126
apps/formbricks-com/app/docs/integrations/notion/page.mdx
Normal 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!
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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't be reflected in your notion database.
|
||||
</>
|
||||
);
|
||||
case ERRORS.MAPPING:
|
||||
return (
|
||||
<>
|
||||
- <i>"{ques.name}"</i> of type{" "}
|
||||
<b>{questionTypes.find((qt) => qt.id === ques.type)?.label}</b> can't be mapped to the
|
||||
column <i>"{col.name}"</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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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!");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
76
apps/web/app/api/v1/integrations/notion/callback/route.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
41
apps/web/app/api/v1/integrations/notion/route.ts
Normal 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
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "IntegrationType" ADD VALUE 'notion';
|
||||
@@ -350,6 +350,7 @@ enum EnvironmentType {
|
||||
|
||||
enum IntegrationType {
|
||||
googleSheets
|
||||
notion
|
||||
airtable
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,6 +45,7 @@ export async function createOrUpdateIntegration(
|
||||
|
||||
integrationCache.revalidate({
|
||||
environmentId,
|
||||
type: integrationData.type,
|
||||
});
|
||||
return integration;
|
||||
} catch (error) {
|
||||
|
||||
72
packages/lib/notion/service.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
89
packages/types/integration/notion.ts
Normal 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>;
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||