diff --git a/.env.example b/.env.example index 3334afca0f..5058d8c71f 100644 --- a/.env.example +++ b/.env.example @@ -114,4 +114,12 @@ NEXT_PUBLIC_FORMBRICKS_API_HOST= NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID= NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID= +# Oauth credentials for Google sheet integration +GOOGLE_SHEETS_CLIENT_ID= +GOOGLE_SHEETS_CLIENT_SECRET= +GOOGLE_SHEETS_REDIRECT_URL= + +# Oauth credentials for Airtable integration +AIR_TABLE_CLIENT_ID= + */ diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/Home.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/Home.tsx new file mode 100644 index 0000000000..ceeeff6694 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/Home.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { timeSince } from "@formbricks/lib/time"; +import { TEnvironment } from "@formbricks/types/v1/environment"; +import { TSurvey } from "@formbricks/types/v1/surveys"; +import { Button } from "@formbricks/ui/Button"; +import { DeleteDialog } from "@formbricks/ui/DeleteDialog"; +import AddIntegrationModal, { + IntegrationModalInputs, +} from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal"; +import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/airtable/actions"; +import { useState } from "react"; +import { toast } from "react-hot-toast"; +import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller"; +import { TIntegrationAirtable } from "@formbricks/types/v1/integration/airtable"; +import { TIntegrationItem } from "@formbricks/types/v1/integration"; +interface handleModalProps { + airtableIntegration: TIntegrationAirtable; + environment: TEnvironment; + environmentId: string; + setIsConnected: (data: boolean) => void; + surveys: TSurvey[]; + airtableArray: TIntegrationItem[]; +} + +const tableHeaders = ["Survey", "Table Name", "Questions", "Updated At"]; + +export default function Home(props: handleModalProps) { + const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props; + const [isDeleting, setisDeleting] = useState(false); + const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false); + const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>( + null + ); + const [isModalOpen, setIsModalOpen] = useState(false); + + const integrationData = airtableIntegration?.config?.data ?? []; + + const handleDeleteIntegration = async () => { + try { + setisDeleting(true); + await deleteIntegrationAction(airtableIntegration.id); + setIsConnected(false); + toast.success("Integration removed successfully"); + } catch (error) { + toast.error(error.message); + } finally { + setisDeleting(false); + setIsDeleteIntegrationModalOpen(false); + } + }; + + const handleModal = (val: boolean) => { + setIsModalOpen(val); + }; + + const data = defaultValues + ? { isEditMode: true as const, defaultData: defaultValues } + : { isEditMode: false as const }; + return ( +
+
+
+ + { + setIsDeleteIntegrationModalOpen(true); + }}> + Connected with {airtableIntegration.config.email} + +
+ +
+ + {integrationData.length ? ( +
+
+ {tableHeaders.map((header, idx) => ( + + ))} +
+ + {integrationData.map((data, index) => ( +
{ + setDefaultValues({ + base: data.baseId, + questions: data.questionIds, + survey: data.surveyId, + table: data.tableId, + index, + }); + setIsModalOpen(true); + }}> +
{data.surveyName}
+
{data.tableName}
+
{data.questions}
+
{timeSince(data.createdAt.toString())}
+
+ ))} +
+ ) : ( +
+ +
+ )} + + + + {isModalOpen && ( + + )} +
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/actions.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/actions.ts new file mode 100644 index 0000000000..a255c036b8 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/actions.ts @@ -0,0 +1,17 @@ +"use server"; + +import { getAirtableTables } from "@formbricks/lib/airtable/service"; +import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/integration/service"; +import { TIntegrationInput } from "@formbricks/types/v1/integration"; + +export async function upsertIntegrationAction(environmentId: string, integrationData: TIntegrationInput) { + return await createOrUpdateIntegration(environmentId, integrationData); +} + +export async function deleteIntegrationAction(integrationId: string) { + return await deleteIntegration(integrationId); +} + +export async function refreshTablesAction(environmentId: string) { + return await getAirtableTables(environmentId); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx new file mode 100644 index 0000000000..0a26f3e5fb --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx @@ -0,0 +1,391 @@ +"use client"; + +import { + TIntegrationAirtableTables, + TIntegrationAirtable, + TIntegrationAirtableConfigData, + TIntegrationAirtableInput, +} from "@formbricks/types/v1/integration/airtable"; +import { TSurvey } from "@formbricks/types/v1/surveys"; +import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select"; +import { Button } from "@formbricks/ui/Button"; +import { Checkbox } from "@formbricks/ui/Checkbox"; +import { Label } from "@formbricks/ui/Label"; +import { Modal } from "@formbricks/ui/Modal"; +import AirtableLogo from "../images/airtable.svg"; +import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Control, Controller, UseFormSetValue, useForm } from "react-hook-form"; +import { toast } from "react-hot-toast"; +import { upsertIntegrationAction } from "../actions"; +import { TIntegrationItem } from "@formbricks/types/v1/integration"; + +type EditModeProps = + | { isEditMode: false; defaultData?: never } + | { isEditMode: true; defaultData: IntegrationModalInputs & { index: number } }; + +type AddIntegrationModalProps = { + open: boolean; + setOpenWithStates: (v: boolean) => void; + environmentId: string; + airtableArray: TIntegrationItem[]; + surveys: TSurvey[]; + airtableIntegration: TIntegrationAirtable; +} & EditModeProps; + +export type IntegrationModalInputs = { + base: string; + table: string; + survey: string; + questions: string[]; +}; + +function NoBaseFoundError() { + return ( + + No Airbase bases found + create a Airbase base + + ); +} + +interface BaseSelectProps { + control: Control; + isLoading: boolean; + fetchTable: (val: string) => Promise; + airtableArray: TIntegrationItem[]; + setValue: UseFormSetValue; + defaultValue: string | undefined; +} + +function BaseSelect({ + airtableArray, + control, + fetchTable, + isLoading, + setValue, + defaultValue, +}: BaseSelectProps) { + return ( +
+ +
+ ( + + )} + /> +
+
+ ); +} + +export default function AddIntegrationModal(props: AddIntegrationModalProps) { + const { + open, + setOpenWithStates, + environmentId, + airtableArray, + surveys, + airtableIntegration, + isEditMode, + defaultData, + } = props; + const router = useRouter(); + const [tables, setTables] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const { handleSubmit, control, watch, setValue, reset } = useForm(); + + useEffect(() => { + if (isEditMode) { + const { index: _index, ...rest } = defaultData; + reset(rest); + fetchTable(defaultData.base); + } else { + reset(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEditMode]); + + const survey = watch("survey"); + const selectedSurvey = surveys.find((item) => item.id === survey); + const submitHandler = async (data: IntegrationModalInputs) => { + try { + if (!data.base || data.base === "") { + throw new Error("Please select a base"); + } + + if (!data.table || data.table === "") { + throw new Error("Please select a table"); + } + + if (!selectedSurvey) { + throw new Error("Please select a survey"); + } + + if (data.questions.length === 0) { + throw new Error("Please select at least one question"); + } + + const airtableIntegrationData: TIntegrationAirtableInput = { + type: "airtable", + config: { + key: airtableIntegration?.config?.key, + data: airtableIntegration.config.data ?? [], + email: airtableIntegration?.config?.email, + }, + }; + + const currentTable = tables.find((item) => item.id === data.table); + const integrationData: TIntegrationAirtableConfigData = { + surveyId: selectedSurvey.id, + surveyName: selectedSurvey.name, + questionIds: data.questions, + questions: + data.questions.length === selectedSurvey.questions.length ? "All questions" : "Selected questions", + createdAt: new Date(), + baseId: data.base, + tableId: data.table, + tableName: currentTable?.name ?? "", + }; + + if (isEditMode) { + // update action + airtableIntegrationData.config!.data[defaultData.index] = integrationData; + } else { + // create action + airtableIntegrationData.config?.data.push(integrationData); + } + + const actionMessage = isEditMode ? "updated" : "added"; + + await upsertIntegrationAction(environmentId, airtableIntegrationData); + toast.success(`Integration ${actionMessage} successfully`); + handleClose(); + } catch (e) { + toast.error(e.message); + } + }; + + const handleTable = async (baseId: string) => { + const data = await fetchTables(environmentId, baseId); + + if (data.tables) { + setTables(data.tables); + } + }; + + const fetchTable = async (val: string) => { + setIsLoading(true); + await handleTable(val); + setIsLoading(false); + }; + + const handleClose = () => { + reset(); + setOpenWithStates(false); + }; + + const handleDelete = async (index: number) => { + try { + const integrationCopy = { ...airtableIntegration }; + integrationCopy.config.data.splice(index, 1); + + await upsertIntegrationAction(environmentId, integrationCopy); + handleClose(); + router.refresh(); + + toast.success(`Integration deleted successfully`); + } catch (e) { + toast.error(e.message); + } + }; + + return ( + +
+
+
+
+ Airbase logo +
+
+
Link Airbase Table
+
Sync responses with a Airbase table
+
+
+
+
+
+
+
+ {airtableArray.length ? ( + + ) : ( + + )} + +
+ +
+ ( + + )} + /> +
+
+ + {surveys.length ? ( +
+ +
+ ( + + )} + /> +
+
+ ) : null} + + {!surveys.length ? ( +

+ You have to create a survey to be able to setup this integration +

+ ) : null} + + {survey && selectedSurvey && ( +
+ +
+
+ {selectedSurvey?.questions.map((question) => ( + ( +
+ +
+ )} + /> + ))} +
+
+
+ )} + +
+ {isEditMode ? ( + + ) : ( + + )} + + +
+
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper.tsx new file mode 100644 index 0000000000..56cfca98d2 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper.tsx @@ -0,0 +1,49 @@ +"use client"; +import Connect from "./Connect"; +import Home from "../Home"; +import { useState } from "react"; +import { TSurvey } from "@formbricks/types/v1/surveys"; +import { TEnvironment } from "@formbricks/types/v1/environment"; +import { TIntegrationAirtable } from "@formbricks/types/v1/integration/airtable"; +import { TIntegrationItem } from "@formbricks/types/v1/integration"; + +interface AirtableWrapperProps { + environmentId: string; + airtableArray: TIntegrationItem[]; + airtableIntegration?: TIntegrationAirtable; + surveys: TSurvey[]; + environment: TEnvironment; + enabled: boolean; + webAppUrl: string; +} + +export default function AirtableWrapper({ + environmentId, + airtableArray, + airtableIntegration, + surveys, + environment, + enabled, + webAppUrl, +}: AirtableWrapperProps) { + const [isConnected, setIsConnected_] = useState( + airtableIntegration ? airtableIntegration.config?.key : false + ); + + const setIsConnected = (data: boolean) => { + setIsConnected_(data); + }; + + return isConnected && airtableIntegration ? ( + + ) : ( + + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/Connect.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/Connect.tsx new file mode 100644 index 0000000000..ca79bfba12 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/Connect.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable"; +import FormbricksLogo from "@/images/logo.svg"; +import { Button } from "@formbricks/ui/Button"; +import Image from "next/image"; +import { useState } from "react"; +import AirtableLogo from "../images/airtable.svg"; + +interface AirtableConnectProps { + enabled: boolean; + environmentId: string; + webAppUrl: string; +} + +export default function AirtableConnect({ environmentId, enabled, webAppUrl }: AirtableConnectProps) { + const [isConnecting, setIsConnecting] = useState(false); + const handleGoogleLogin = async () => { + setIsConnecting(true); + authorize(environmentId, webAppUrl).then((url: string) => { + if (url) { + window.location.replace(url); + } + }); + }; + + return ( +
+
+
+
+ Formbricks Logo +
+
+ Airtable Logo +
+
+

Sync responses directly with Airtable.

+ {!enabled && ( +

+ Airtable Integration is not configured in your instance of Formbricks. +

+ )} + +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/images/airtable.svg b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/images/airtable.svg new file mode 100644 index 0000000000..a379231028 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/images/airtable.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable.ts new file mode 100644 index 0000000000..e026980144 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable.ts @@ -0,0 +1,26 @@ +import { TIntegrationAirtableTables } from "@formbricks/types/v1/integration/airtable"; + +export const fetchTables = async (environmentId: string, baseId: string) => { + const res = await fetch(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, { + method: "GET", + headers: { environmentId: environmentId }, + cache: "no-store", + }); + + return res.json() as Promise; +}; + +export const authorize = async (environmentId: string, apiHost: string): Promise => { + const res = await fetch(`${apiHost}/api/v1/integrations/airtable`, { + 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.authUrl; + return authUrl; +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx new file mode 100644 index 0000000000..f8a567a37a --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx @@ -0,0 +1,47 @@ +import AirtableWrapper from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"; +import { getAirtableTables } from "@formbricks/lib/airtable/service"; +import { AIR_TABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants"; +import { getEnvironment } from "@formbricks/lib/environment/service"; +import { getIntegrations } from "@formbricks/lib/integration/service"; +import { getSurveys } from "@formbricks/lib/survey/service"; +import { TIntegrationItem } from "@formbricks/types/v1/integration"; +import { TIntegrationAirtable } from "@formbricks/types/v1/integration/airtable"; +import GoBackButton from "@formbricks/ui/GoBackButton"; + +export default async function Airtable({ params }) { + const enabled = !!AIR_TABLE_CLIENT_ID; + const [surveys, integrations, environment] = await Promise.all([ + getSurveys(params.environmentId), + getIntegrations(params.environmentId), + getEnvironment(params.environmentId), + ]); + if (!environment) { + throw new Error("Environment not found"); + } + + const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find( + (integration): integration is TIntegrationAirtable => integration.type === "airtable" + ); + + let airtableArray: TIntegrationItem[] = []; + if (airtableIntegration && airtableIntegration.config.key) { + airtableArray = await getAirtableTables(params.environmentId); + } + + return ( + <> + +
+ +
+ + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/actions.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/actions.ts index 0cde7e78c2..a6e9bc2f08 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/actions.ts @@ -1,15 +1,19 @@ "use server"; import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { getServerSession } from "next-auth"; import { getSpreadSheets } from "@formbricks/lib/googleSheet/service"; import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/integration/service"; -import { TIntegrationInput } from "@formbricks/types/v1/integrations"; +import { getServerSession } from "next-auth"; + +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { canUserAccessIntegration } from "@formbricks/lib/integration/auth"; import { AuthorizationError } from "@formbricks/types/v1/errors"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { TIntegrationGoogleSheetsInput } from "@formbricks/types/v1/integration/googleSheet"; -export async function upsertIntegrationAction(environmentId: string, integrationData: TIntegrationInput) { +export async function createOrUpdateIntegrationAction( + environmentId: string, + integrationData: TIntegrationGoogleSheetsInput +) { return await createOrUpdateIntegration(environmentId, integrationData); } diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx index 9add84de39..9c90cdeba9 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx @@ -1,31 +1,31 @@ -import { TSurvey } from "@formbricks/types/v1/surveys"; +import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions"; import { - TGoogleSheetIntegration, - TGoogleSheetsConfigData, - TGoogleSpreadsheet, - TIntegrationInput, -} from "@formbricks/types/v1/integrations"; + TIntegrationGoogleSheets, + TIntegrationGoogleSheetsConfigData, + TIntegrationGoogleSheetsInput, +} from "@formbricks/types/v1/integration/googleSheet"; +import { TSurvey } from "@formbricks/types/v1/surveys"; import { Button } from "@formbricks/ui/Button"; -import { Label } from "@formbricks/ui/Label"; import { Checkbox } from "@formbricks/ui/Checkbox"; -import GoogleSheetLogo from "@/images/google-sheets-small.png"; -import { useState, useEffect } from "react"; +import { Label } from "@formbricks/ui/Label"; +import { Modal } from "@formbricks/ui/Modal"; +import { ChevronDownIcon } from "@heroicons/react/24/solid"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import Image from "next/image"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; -import Image from "next/image"; -import { Modal } from "@formbricks/ui/Modal"; -import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; -import { ChevronDownIcon } from "@heroicons/react/24/solid"; -import { upsertIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions"; +import GoogleSheetLogo from "../images/google-sheets-small.png"; +import { TIntegrationItem } from "@formbricks/types/v1/integration"; interface AddWebhookModalProps { environmentId: string; open: boolean; surveys: TSurvey[]; setOpen: (v: boolean) => void; - spreadsheets: TGoogleSpreadsheet[]; - googleSheetIntegration: TGoogleSheetIntegration; - selectedIntegration?: (TGoogleSheetsConfigData & { index: number }) | null; + spreadsheets: TIntegrationItem[]; + googleSheetIntegration: TIntegrationGoogleSheets; + selectedIntegration?: (TIntegrationGoogleSheetsConfigData & { index: number }) | null; } export default function AddIntegrationModal({ @@ -55,7 +55,7 @@ export default function AddIntegrationModal({ const [selectedSpreadsheet, setSelectedSpreadsheet] = useState(null); const [isDeleting, setIsDeleting] = useState(null); const existingIntegrationData = googleSheetIntegration?.config?.data; - const googleSheetIntegrationData: TIntegrationInput = { + const googleSheetIntegrationData: TIntegrationGoogleSheetsInput = { type: "googleSheets", config: { key: googleSheetIntegration?.config?.key, @@ -120,7 +120,7 @@ export default function AddIntegrationModal({ // create action googleSheetIntegrationData.config!.data.push(integrationData); } - await upsertIntegrationAction(environmentId, googleSheetIntegrationData); + await createOrUpdateIntegrationAction(environmentId, googleSheetIntegrationData); toast.success(`Integration ${selectedIntegration ? "updated" : "added"} successfully`); resetForm(); setOpen(false); @@ -153,7 +153,7 @@ export default function AddIntegrationModal({ googleSheetIntegrationData.config!.data.splice(selectedIntegration!.index, 1); try { setIsDeleting(true); - await upsertIntegrationAction(environmentId, googleSheetIntegrationData); + await createOrUpdateIntegrationAction(environmentId, googleSheetIntegrationData); toast.success("Integration removed successfully"); setOpen(false); } catch (error) { diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/Connect.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/Connect.tsx index 32fcb7692d..7eebb9be3c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/Connect.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/Connect.tsx @@ -1,6 +1,6 @@ "use client"; -import GoogleSheetLogo from "@/images/google-sheets-small.png"; +import GoogleSheetLogo from "../images/google-sheets-small.png"; import FormbricksLogo from "@/images/logo.svg"; import { authorize } from "../lib/google"; import { Button } from "@formbricks/ui/Button"; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper.tsx index 9517c17a09..90db20240b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper.tsx @@ -1,24 +1,24 @@ "use client"; -import { useState } from "react"; -import Home from "./Home"; -import Connect from "./Connect"; -import AddIntegrationModal from "./AddIntegrationModal"; -import { - TGoogleSheetIntegration, - TGoogleSheetsConfigData, - TGoogleSpreadsheet, -} from "@formbricks/types/v1/integrations"; -import { TSurvey } from "@formbricks/types/v1/surveys"; import { refreshSheetAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions"; import { TEnvironment } from "@formbricks/types/v1/environment"; +import { TIntegrationItem } from "@formbricks/types/v1/integration"; +import { + TIntegrationGoogleSheets, + TIntegrationGoogleSheetsConfigData, +} from "@formbricks/types/v1/integration/googleSheet"; +import { TSurvey } from "@formbricks/types/v1/surveys"; +import { useState } from "react"; +import AddIntegrationModal from "./AddIntegrationModal"; +import Connect from "./Connect"; +import Home from "./Home"; interface GoogleSheetWrapperProps { enabled: boolean; environment: TEnvironment; surveys: TSurvey[]; - spreadSheetArray: TGoogleSpreadsheet[]; - googleSheetIntegration: TGoogleSheetIntegration | undefined; + spreadSheetArray: TIntegrationItem[]; + googleSheetIntegration?: TIntegrationGoogleSheets; webAppUrl: string; } @@ -36,7 +36,7 @@ export default function GoogleSheetWrapper({ const [spreadsheets, setSpreadsheets] = useState(spreadSheetArray); const [isModalOpen, setModalOpen] = useState(false); const [selectedIntegration, setSelectedIntegration] = useState< - (TGoogleSheetsConfigData & { index: number }) | null + (TIntegrationGoogleSheetsConfigData & { index: number }) | null >(null); const refreshSheet = async () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/Home.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/Home.tsx index 334da77e18..406503d447 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/Home.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/Home.tsx @@ -1,21 +1,24 @@ "use client"; import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions"; -import { DeleteDialog } from "@formbricks/ui/DeleteDialog"; -import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller"; import { timeSince } from "@formbricks/lib/time"; import { TEnvironment } from "@formbricks/types/v1/environment"; -import { TGoogleSheetIntegration, TGoogleSheetsConfigData } from "@formbricks/types/v1/integrations"; +import { + TIntegrationGoogleSheets, + TIntegrationGoogleSheetsConfigData, +} from "@formbricks/types/v1/integration/googleSheet"; import { Button } from "@formbricks/ui/Button"; +import { DeleteDialog } from "@formbricks/ui/DeleteDialog"; +import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller"; import { useState } from "react"; import toast from "react-hot-toast"; interface HomeProps { environment: TEnvironment; - googleSheetIntegration: TGoogleSheetIntegration; + googleSheetIntegration: TIntegrationGoogleSheets; setOpenAddIntegrationModal: (v: boolean) => void; setIsConnected: (v: boolean) => void; - setSelectedIntegration: (v: (TGoogleSheetsConfigData & { index: number }) | null) => void; + setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void; refreshSheet: () => void; } diff --git a/apps/web/images/google-sheets-small.png b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/images/google-sheets-small.png similarity index 100% rename from apps/web/images/google-sheets-small.png rename to apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/images/google-sheets-small.png diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx index 2053fd716e..0136471dca 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx @@ -1,16 +1,17 @@ import GoogleSheetWrapper from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper"; -import GoBackButton from "@formbricks/ui/GoBackButton"; +import { + GOOGLE_SHEETS_CLIENT_ID, + GOOGLE_SHEETS_CLIENT_SECRET, + GOOGLE_SHEETS_REDIRECT_URL, + WEBAPP_URL, +} from "@formbricks/lib/constants"; +import { getEnvironment } from "@formbricks/lib/environment/service"; import { getSpreadSheets } from "@formbricks/lib/googleSheet/service"; import { getIntegrations } from "@formbricks/lib/integration/service"; import { getSurveys } from "@formbricks/lib/survey/service"; -import { TGoogleSheetIntegration, TGoogleSpreadsheet } from "@formbricks/types/v1/integrations"; -import { - GOOGLE_SHEETS_CLIENT_ID, - WEBAPP_URL, - GOOGLE_SHEETS_CLIENT_SECRET, - GOOGLE_SHEETS_REDIRECT_URL, -} from "@formbricks/lib/constants"; -import { getEnvironment } from "@formbricks/lib/environment/service"; +import { TIntegrationItem } from "@formbricks/types/v1/integration"; +import { TIntegrationGoogleSheets } from "@formbricks/types/v1/integration/googleSheet"; +import GoBackButton from "@formbricks/ui/GoBackButton"; export default async function GoogleSheet({ params }) { const enabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL); @@ -23,10 +24,10 @@ export default async function GoogleSheet({ params }) { throw new Error("Environment not found"); } - const googleSheetIntegration: TGoogleSheetIntegration | undefined = integrations?.find( - (integration): integration is TGoogleSheetIntegration => integration.type === "googleSheets" + const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find( + (integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets" ); - let spreadSheetArray: TGoogleSpreadsheet[] = []; + let spreadSheetArray: TIntegrationItem[] = []; if (googleSheetIntegration && googleSheetIntegration.config.key) { spreadSheetArray = await getSpreadSheets(params.environmentId); } diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx index 2432fd6cee..d2dda41507 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx @@ -1,9 +1,10 @@ +import AirtableLogo from "./airtable/images/airtable.svg"; +import GoogleSheetsLogo from "./google-sheets/images/google-sheets-small.png"; import JsLogo from "@/images/jslogo.png"; +import MakeLogo from "@/images/make-small.png"; +import n8nLogo from "@/images/n8n.png"; import WebhookLogo from "@/images/webhook.png"; import ZapierLogo from "@/images/zapier-small.png"; -import GoogleSheetsLogo from "@/images/google-sheets-small.png"; -import n8nLogo from "@/images/n8n.png"; -import MakeLogo from "@/images/make-small.png"; import { Card } from "@formbricks/ui/Card"; import Image from "next/image"; import { getCountOfWebhooksBasedOnSource } from "@formbricks/lib/webhook/service"; @@ -24,6 +25,8 @@ export default async function IntegrationsPage({ params }) { (integration) => integration.type === "googleSheets" ); + const containsAirtableIntegration = integrations.some((integration) => integration.type === "airtable"); + const integrationCards = [ { docsHref: "https://formbricks.com/docs/getting-started/framework-guides#next-js", @@ -76,6 +79,19 @@ export default async function IntegrationsPage({ params }) { connected: containsGoogleSheetIntegration ? true : false, statusText: containsGoogleSheetIntegration ? "Connected" : "Not Connected", }, + { + connectHref: `/environments/${params.environmentId}/integrations/airtable`, + connectText: `${containsAirtableIntegration ? "Manage Table" : "Connect"}`, + connectNewTab: false, + docsHref: "https://formbricks.com/docs/integrations/airtable", + docsText: "Docs", + docsNewTab: true, + label: "Airtable", + description: "Instantly populate your airtable table with survey data", + icon: Airtable Logo, + connected: containsAirtableIntegration ? true : false, + statusText: containsAirtableIntegration ? "Connected" : "Not Connected", + }, { docsHref: "https://formbricks.com/docs/integrations/n8n", docsText: "Docs", diff --git a/apps/web/app/api/pipeline/lib/handleIntegrations.ts b/apps/web/app/api/pipeline/lib/handleIntegrations.ts index 09ba00fb17..d3d74d9c50 100644 --- a/apps/web/app/api/pipeline/lib/handleIntegrations.ts +++ b/apps/web/app/api/pipeline/lib/handleIntegrations.ts @@ -1,19 +1,37 @@ +import { writeData as airtableWriteData } from "@formbricks/lib/airtable/service"; +import { TIntegration } from "@formbricks/types/v1/integration"; import { writeData } from "@formbricks/lib/googleSheet/service"; import { getSurvey } from "@formbricks/lib/survey/service"; -import { TGoogleSheetIntegration, TIntegration } from "@formbricks/types/v1/integrations"; import { TPipelineInput } from "@formbricks/types/v1/pipelines"; +import { TIntegrationGoogleSheets } from "@formbricks/types/v1/integration/googleSheet"; +import { TIntegrationAirtable } from "@formbricks/types/v1/integration/airtable"; export async function handleIntegrations(integrations: TIntegration[], data: TPipelineInput) { for (const integration of integrations) { switch (integration.type) { case "googleSheets": - await handleGoogleSheetsIntegration(integration as TGoogleSheetIntegration, data); + await handleGoogleSheetsIntegration(integration as TIntegrationGoogleSheets, data); + break; + case "airtable": + await handleAirtableIntegration(integration as TIntegrationAirtable, data); break; } } } -async function handleGoogleSheetsIntegration(integration: TGoogleSheetIntegration, data: TPipelineInput) { +async function handleAirtableIntegration(integration: TIntegrationAirtable, data: TPipelineInput) { + 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); + + await airtableWriteData(integration.config.key, element, values); + } + } + } +} + +async function handleGoogleSheetsIntegration(integration: TIntegrationGoogleSheets, data: TPipelineInput) { if (integration.config.data.length > 0) { for (const element of integration.config.data) { if (element.surveyId === data.surveyId) { diff --git a/apps/web/app/api/v1/integrations/airtable/callback/route.ts b/apps/web/app/api/v1/integrations/airtable/callback/route.ts new file mode 100644 index 0000000000..684c2ba1bb --- /dev/null +++ b/apps/web/app/api/v1/integrations/airtable/callback/route.ts @@ -0,0 +1,73 @@ +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { connectAirtable, fetchAirtableAuthToken } from "@formbricks/lib/airtable/service"; +import { AIR_TABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants"; +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; +import * as z from "zod"; + +async function getEmail(token: string) { + const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const res_ = await req_.json(); + + return z.string().parse(res_?.email); +} + +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 session = await getServerSession(authOptions); + + if (!environmentId) { + return NextResponse.json({ error: "Invalid environmentId" }); + } + + if (!session) { + return NextResponse.json({ Error: "Invalid session" }, { status: 400 }); + } + + if (code && typeof code !== "string") { + return NextResponse.json({ message: "`code` must be a string" }, { status: 400 }); + } + const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId); + if (!canUserAccessEnvironment) { + return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 }); + } + + const client_id = AIR_TABLE_CLIENT_ID; + const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback"; + const code_verifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64"); + + if (!client_id) return NextResponse.json({ Error: "Airtable client id is missing" }, { status: 400 }); + if (!redirect_uri) return NextResponse.json({ Error: "Airtable redirect url is missing" }, { status: 400 }); + + const formData = { + grant_type: "authorization_code", + code, + redirect_uri, + client_id, + code_verifier, + }; + + try { + const key = await fetchAirtableAuthToken(formData); + + const email = await getEmail(key.access_token); + + await connectAirtable({ + environmentId, + email, + key, + }); + return NextResponse.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`); + } catch (error) {} + + NextResponse.json({ Error: "unknown error occurred" }, { status: 400 }); +} diff --git a/apps/web/app/api/v1/integrations/airtable/route.ts b/apps/web/app/api/v1/integrations/airtable/route.ts new file mode 100644 index 0000000000..f385004c80 --- /dev/null +++ b/apps/web/app/api/v1/integrations/airtable/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { getServerSession } from "next-auth"; +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import crypto from "crypto"; + +import { AIR_TABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants"; + +const scope = `data.records:read data.records:write schema.bases:read schema.bases:write user.email:read`; + +export async function GET(req: NextRequest) { + const environmentId = req.headers.get("environmentId"); + const session = await getServerSession(authOptions); + + if (!environmentId) { + return NextResponse.json({ Error: "environmentId is missing" }, { status: 400 }); + } + + if (!session) { + return NextResponse.json({ Error: "Invalid session" }, { status: 400 }); + } + + const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId); + if (!canUserAccessEnvironment) { + return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 }); + } + + const client_id = AIR_TABLE_CLIENT_ID; + const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback"; + if (!client_id) return NextResponse.json({ Error: "Airtable client id is missing" }, { status: 400 }); + if (!redirect_uri) return NextResponse.json({ Error: "Airtable redirect url is missing" }, { status: 400 }); + + const codeVerifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64"); + + const codeChallengeMethod = "S256"; + const codeChallenge = crypto + .createHash("sha256") + .update(codeVerifier) // hash the code verifier with the sha256 algorithm + .digest("base64") // base64 encode, needs to be transformed to base64url + .replace(/=/g, "") // remove = + .replace(/\+/g, "-") // replace + with - + .replace(/\//g, "_"); // replace / with _ now base64url encoded + + const authUrl = new URL("https://airtable.com/oauth2/v1/authorize"); + + authUrl.searchParams.append("client_id", client_id); + authUrl.searchParams.append("redirect_uri", redirect_uri); + authUrl.searchParams.append("state", environmentId); + authUrl.searchParams.append("scope", scope); + authUrl.searchParams.append("response_type", "code"); + authUrl.searchParams.append("code_challenge_method", codeChallengeMethod); + authUrl.searchParams.append("code_challenge", codeChallenge); + + return NextResponse.json({ authUrl: authUrl.toString() }, { status: 200 }); +} diff --git a/apps/web/app/api/v1/integrations/airtable/tables/route.ts b/apps/web/app/api/v1/integrations/airtable/tables/route.ts new file mode 100644 index 0000000000..9704558b73 --- /dev/null +++ b/apps/web/app/api/v1/integrations/airtable/tables/route.ts @@ -0,0 +1,44 @@ +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { getTables } from "@formbricks/lib/airtable/service"; +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { getIntegrationByType } from "@formbricks/lib/integration/service"; +import { TIntegrationAirtable } from "@formbricks/types/v1/integration/airtable"; +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; +import * as z from "zod"; + +export async function GET(req: NextRequest) { + const url = req.url; + const environmentId = req.headers.get("environmentId"); + const queryParams = new URLSearchParams(url.split("?")[1]); + const session = await getServerSession(authOptions); + const baseId = z.string().safeParse(queryParams.get("baseId")); + + if (!baseId.success) { + return NextResponse.json({ Error: "Base Id is Required" }, { status: 400 }); + } + + if (!session) { + return NextResponse.json({ Error: "Invalid session" }, { status: 400 }); + } + + if (!environmentId) { + return NextResponse.json({ Error: "environmentId is missing" }, { status: 400 }); + } + + const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId); + if (!canUserAccessEnvironment || !environmentId) { + return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 }); + } + + const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable; + console.log(integration); + + if (!integration) { + return NextResponse.json({ Error: "integration not found" }, { status: 401 }); + } + + const tables = await getTables(integration.config.key, baseId.data); + + return NextResponse.json(tables, { status: 200 }); +} diff --git a/apps/web/app/lib/templates.ts b/apps/web/app/lib/templates.ts index e634d826da..433dde0428 100644 --- a/apps/web/app/lib/templates.ts +++ b/apps/web/app/lib/templates.ts @@ -1,4 +1,4 @@ -import { Question } from "@/../../packages/types/questions"; +import { Question } from "@formbricks/types/questions"; import { TTemplate } from "@formbricks/types/v1/templates"; export const replaceQuestionPresetPlaceholders = (question: Question, product) => { diff --git a/apps/web/app/s/[surveyId]/components/PinScreen.tsx b/apps/web/app/s/[surveyId]/components/PinScreen.tsx index dc08248e36..1cb3f66042 100644 --- a/apps/web/app/s/[surveyId]/components/PinScreen.tsx +++ b/apps/web/app/s/[surveyId]/components/PinScreen.tsx @@ -1,12 +1,12 @@ "use client"; import type { NextPage } from "next"; -import { TProduct } from "@/../../packages/types/v1/product"; -import { TResponse } from "@/../../packages/types/v1/responses"; +import { TProduct } from "@formbricks/types/v1/product"; +import { TResponse } from "@formbricks/types/v1/responses"; import { OTPInput } from "@formbricks/ui/OTPInput"; import { useCallback, useEffect, useState } from "react"; import { validateSurveyPin } from "@/app/s/[surveyId]/actions"; -import { TSurvey } from "@/../../packages/types/v1/surveys"; +import { TSurvey } from "@formbricks/types/v1/surveys"; import { TSurveyPinValidationResponseError } from "@/app/s/[surveyId]/types"; import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey"; import { cn } from "@formbricks/lib/cn"; diff --git a/apps/web/env.mjs b/apps/web/env.mjs index e3419373f3..5bd66c65d2 100644 --- a/apps/web/env.mjs +++ b/apps/web/env.mjs @@ -54,6 +54,9 @@ export const env = createEnv({ GOOGLE_SHEETS_CLIENT_ID: z.string().optional(), GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(), GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(), + AIR_TABLE_CLIENT_ID: z.string().optional(), + AWS_ACCESS_KEY: z.string().optional(), + AWS_SECRET_KEY: z.string().optional(), S3_ACCESS_KEY: z.string().optional(), S3_SECRET_KEY: z.string().optional(), S3_REGION: z.string().optional(), @@ -130,5 +133,6 @@ export const env = createEnv({ SURVEY_BASE_URL: process.env.SURVEY_BASE_URL, SHORT_SURVEY_BASE_URL: process.env.SHORT_SURVEY_BASE_URL, NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, + AIR_TABLE_CLIENT_ID: process.env.AIR_TABLE_CLIENT_ID, }, }); diff --git a/packages/database/jsonTypes.ts b/packages/database/jsonTypes.ts index a239f8b8c2..926f7ee161 100644 --- a/packages/database/jsonTypes.ts +++ b/packages/database/jsonTypes.ts @@ -1,5 +1,5 @@ import { TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses"; -import { TIntegrationConfig } from "@formbricks/types/v1/integrations"; +import { TIntegrationConfig } from "@formbricks/types/v1/integration"; import { TResponseData, TResponseMeta, TResponsePersonAttributes } from "@formbricks/types/v1/responses"; import { TSurveyWelcomeCard, diff --git a/packages/database/migrations/20231019160204_add_airtable_integration/migration.sql b/packages/database/migrations/20231019160204_add_airtable_integration/migration.sql new file mode 100644 index 0000000000..55ef1e08da --- /dev/null +++ b/packages/database/migrations/20231019160204_add_airtable_integration/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "IntegrationType" ADD VALUE 'airtable'; diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index d4e5ec5c6d..664ba13ccf 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -330,6 +330,7 @@ enum EnvironmentType { enum IntegrationType { googleSheets + airtable } model Integration { diff --git a/packages/database/zod-utils.ts b/packages/database/zod-utils.ts index 2923a4949d..9946d549df 100644 --- a/packages/database/zod-utils.ts +++ b/packages/database/zod-utils.ts @@ -2,7 +2,7 @@ import z from "zod"; export const ZEventProperties = z.record(z.string()); export { ZActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses"; -export { ZIntegrationConfig } from "@formbricks/types/v1/integrations"; +export { ZIntegrationConfig } from "@formbricks/types/v1/integration"; export { ZResponseData, ZResponsePersonAttributes, ZResponseMeta } from "@formbricks/types/v1/responses"; diff --git a/packages/js/README.md b/packages/js/README.md index a85274bf2b..841f2834d5 100644 --- a/packages/js/README.md +++ b/packages/js/README.md @@ -4,7 +4,7 @@ [![MIT License](https://img.shields.io/badge/License-MIT-red.svg?style=flat-square)](https://opensource.org/licenses/MIT) Please see [Formbricks Docs](https://formbricks.com/docs). -Specifically, [Quickstart/Implementation details](https://formbricks.com/docs/getting-started/quickstart). +Specifically, [Quickstart/Implementation details](https://formbricks.com/docs/getting-started/quickstart-in-app-survey). ## What is Formbricks @@ -33,4 +33,4 @@ if (typeof window !== "undefined") { Replace your-environment-id with your actual environment ID. You can find your environment ID in the **Setup Checklist** in the Formbricks settings. -For more detailed guides for different frameworks, check out our [Next.js](https://formbricks.com/docs/getting-started/nextjs) and [Vue.js](https://formbricks.com/docs/getting-started/vuejs) guides. +For more detailed guides for different frameworks, check out our [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides). diff --git a/packages/lib/airtable/service.ts b/packages/lib/airtable/service.ts new file mode 100644 index 0000000000..87835aaa72 --- /dev/null +++ b/packages/lib/airtable/service.ts @@ -0,0 +1,250 @@ +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/v1/errors"; +import { TIntegrationItem } from "@formbricks/types/v1/integration"; +import { + TIntegrationAirtable, + TIntegrationAirtableConfigData, + TIntegrationAirtableCredential, + TIntegrationAirtableInput, + ZIntegrationAirtableBases, + ZIntegrationAirtableCredential, + ZIntegrationAirtableTables, + ZIntegrationAirtableTablesWithFields, + ZIntegrationAirtableTokenSchema, +} from "@formbricks/types/v1/integration/airtable"; +import { Prisma } from "@prisma/client"; +import { AIR_TABLE_CLIENT_ID } from "../constants"; +import { createOrUpdateIntegration, deleteIntegration, getIntegrationByType } from "../integration/service"; + +interface ConnectAirtableOptions { + environmentId: string; + key: TIntegrationAirtableCredential; + email: string; +} + +export const connectAirtable = async ({ email, environmentId, key }: ConnectAirtableOptions) => { + const type: TIntegrationAirtableInput["type"] = "airtable"; + + const baseData: TIntegrationAirtableInput = { + type, + config: { data: [], key, email }, + }; + + await prisma.integration.upsert({ + where: { + type_environmentId: { + environmentId, + type, + }, + }, + update: { + ...baseData, + environment: { connect: { id: environmentId } }, + }, + create: { + ...baseData, + environment: { connect: { id: environmentId } }, + }, + }); +}; + +export const getBases = async (key: string) => { + const req = await fetch("https://api.airtable.com/v0/meta/bases", { + headers: { + Authorization: `Bearer ${key}`, + }, + }); + + const res = await req.json(); + return ZIntegrationAirtableBases.parse(res); +}; + +const tableFetcher = async (key: TIntegrationAirtableCredential, baseId: string) => { + const req = await fetch(`https://api.airtable.com/v0/meta/bases/${baseId}/tables`, { + headers: { + Authorization: `Bearer ${key.access_token}`, + }, + }); + + const res = await req.json(); + + return res; +}; + +export const getTables = async (key: TIntegrationAirtableCredential, baseId: string) => { + const res = await tableFetcher(key, baseId); + return ZIntegrationAirtableTables.parse(res); +}; + +export const fetchAirtableAuthToken = async (formData: Record) => { + const formBody = Object.keys(formData) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(formData[key])}`) + .join("&"); + + const tokenReq = await fetch("https://airtable.com/oauth2/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formBody, + method: "POST", + }); + + const tokenRes: unknown = await tokenReq.json(); + + const { access_token, expires_in, refresh_token } = ZIntegrationAirtableTokenSchema.parse(tokenRes); + + const expiry_date = new Date(); + expiry_date.setSeconds(expiry_date.getSeconds() + expires_in); + + return { + access_token, + expiry_date: expiry_date.toISOString(), + refresh_token, + }; +}; + +export const getAirtableToken = async (environmentId: string) => { + try { + const airtableIntegration = (await getIntegrationByType( + environmentId, + "airtable" + )) as TIntegrationAirtable; + + const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse( + airtableIntegration?.config.key + ); + + const expiryDate = new Date(expiry_date); + const currentDate = new Date(); + + if (currentDate >= expiryDate) { + const client_id = AIR_TABLE_CLIENT_ID; + + const newToken = await fetchAirtableAuthToken({ + grant_type: "refresh_token", + refresh_token, + client_id, + }); + + await createOrUpdateIntegration(environmentId, { + type: "airtable", + config: { + data: airtableIntegration?.config?.data ?? [], + email: airtableIntegration?.config?.email ?? "", + key: newToken, + }, + }); + + return newToken.access_token; + } + + return access_token; + } catch (error) { + await deleteIntegration(environmentId); + + throw new Error("invalid token"); + } +}; + +export const getAirtableTables = async (environmentId: string) => { + let tables: TIntegrationItem[] = []; + try { + const token = await getAirtableToken(environmentId); + + tables = (await getBases(token)).bases; + + return tables; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + throw error; + } +}; + +const addRecords = async ( + key: TIntegrationAirtableCredential, + baseId: string, + tableId: string, + data: Record +) => { + const req = await fetch(`https://api.airtable.com/v0/${baseId}/${tableId}`, { + method: "POST", + headers: { + Authorization: `Bearer ${key.access_token}`, + "Content-type": "application/json", + }, + body: JSON.stringify({ + fields: data, + typecast: true, + }), + }); + + return await req.json(); +}; + +const addField = async ( + key: TIntegrationAirtableCredential, + baseId: string, + tableId: string, + data: Record +) => { + const req = await fetch(`https://api.airtable.com/v0/meta/bases/${baseId}/tables/${tableId}/fields`, { + method: "POST", + headers: { + Authorization: `Bearer ${key.access_token}`, + "Content-type": "application/json", + }, + body: JSON.stringify(data), + }); + + return await req.json(); +}; + +export const writeData = async ( + key: TIntegrationAirtableCredential, + configData: TIntegrationAirtableConfigData, + values: string[][] +) => { + try { + const responses = values[0]; + const questions = values[1]; + + const data: Record = {}; + for (let i = 0; i < questions.length; i++) { + data[questions[i]] = responses[i]; + } + + const req = await tableFetcher(key, configData.baseId); + const tables = ZIntegrationAirtableTablesWithFields.parse(req).tables; + + const currentTable = tables.find((table) => table.id === configData.tableId); + if (currentTable) { + const currentFields = new Set(currentTable.fields.map((field) => field.name)); + const fieldsToCreate = new Set(); + for (const field of questions) { + const hasField = currentFields.has(field); + if (!hasField) { + fieldsToCreate.add(field); + } + } + + if (fieldsToCreate.size > 0) { + const createFieldPromise: Promise[] = []; + fieldsToCreate.forEach((fieldName) => { + createFieldPromise.push( + addField(key, configData.baseId, configData.tableId, { + name: fieldName, + type: "singleLineText", + }) + ); + }); + + await Promise.all(createFieldPromise); + } + } + await addRecords(key, configData.baseId, configData.tableId, data); + } catch (error: any) { + console.error(error?.message); + } +}; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 615e73382d..06d32741b7 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -48,6 +48,8 @@ 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 AIR_TABLE_CLIENT_ID = env.AIR_TABLE_CLIENT_ID; + export const SMTP_HOST = env.SMTP_HOST; export const SMTP_PORT = env.SMTP_PORT; export const SMTP_SECURE_ENABLED = env.SMTP_SECURE_ENABLED === "1"; diff --git a/packages/lib/googleSheet/service.ts b/packages/lib/googleSheet/service.ts index d959586b65..98cec83aef 100644 --- a/packages/lib/googleSheet/service.ts +++ b/packages/lib/googleSheet/service.ts @@ -1,23 +1,23 @@ import "server-only"; -import { z } from "zod"; -import { validateInputs } from "../utils/validate"; -import { Prisma } from "@prisma/client"; -import { DatabaseError, UnknownError } from "@formbricks/types/v1/errors"; +import { ZString } from "@formbricks/types/v1/common"; import { ZId } from "@formbricks/types/v1/environment"; +import { DatabaseError, UnknownError } from "@formbricks/types/v1/errors"; +import { TIntegrationItem } from "@formbricks/types/v1/integration"; import { - ZGoogleCredential, - TGoogleCredential, - TGoogleSpreadsheet, - TGoogleSheetIntegration, -} from "@formbricks/types/v1/integrations"; + TIntegrationGoogleSheets, + TIntegrationGoogleSheetsCredential, + ZIntegrationGoogleSheetsCredential, +} from "@formbricks/types/v1/integration/googleSheet"; +import { Prisma } from "@prisma/client"; +import { z } from "zod"; import { GOOGLE_SHEETS_CLIENT_ID, GOOGLE_SHEETS_CLIENT_SECRET, GOOGLE_SHEETS_REDIRECT_URL, } from "../constants"; -import { ZString } from "@formbricks/types/v1/common"; import { getIntegrationByType } from "../integration/service"; +import { validateInputs } from "../utils/validate"; const { google } = require("googleapis"); @@ -35,15 +35,15 @@ async function fetchSpreadsheets(auth: any) { } } -export const getSpreadSheets = async (environmentId: string): Promise => { +export const getSpreadSheets = async (environmentId: string): Promise => { validateInputs([environmentId, ZId]); - let spreadsheets: TGoogleSpreadsheet[] = []; + let spreadsheets: TIntegrationItem[] = []; try { const googleIntegration = (await getIntegrationByType( environmentId, "googleSheets" - )) as TGoogleSheetIntegration; + )) as TIntegrationGoogleSheets; if (googleIntegration && googleIntegration.config?.key) { spreadsheets = await fetchSpreadsheets(googleIntegration.config?.key); } @@ -55,9 +55,13 @@ export const getSpreadSheets = async (environmentId: string): Promise; + +export const ZIntegrationAirtableConfigData = z + .object({ + tableId: z.string(), + baseId: z.string(), + tableName: z.string(), + }) + .merge(ZIntegrationBaseSurveyData); + +export type TIntegrationAirtableConfigData = z.infer; + +export const ZIntegrationAirtableConfig = z.object({ + key: ZIntegrationAirtableCredential, + data: z.array(ZIntegrationAirtableConfigData), + email: z.string(), +}); + +export type TIntegrationAirtableConfig = z.infer; + +export const ZIntegrationAirtable = ZIntegrationBase.extend({ + type: z.literal("airtable"), + config: ZIntegrationAirtableConfig, +}); + +export type TIntegrationAirtable = z.infer; + +export const ZIntegrationAirtableInput = z.object({ + type: z.literal("airtable"), + config: ZIntegrationAirtableConfig, +}); + +export type TIntegrationAirtableInput = z.infer; + +export const ZIntegrationAirtableBases = z.object({ + bases: z.array(z.object({ id: z.string(), name: z.string() })), +}); + +export type TIntegrationAirtableBases = z.infer; + +export const ZIntegrationAirtableTables = z.object({ + tables: z.array(z.object({ id: z.string(), name: z.string() })), +}); + +export type TIntegrationAirtableTables = z.infer; + +export const ZIntegrationAirtableTokenSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), + expires_in: z.coerce.number(), +}); + +export type TIntegrationAirtableTokenSchema = z.infer; + +export const ZIntegrationAirtableTablesWithFields = z.object({ + tables: z.array( + z.object({ + id: z.string(), + name: z.string(), + fields: z.array( + z.object({ + id: z.string(), + name: z.string(), + }) + ), + }) + ), +}); + +export type TIntegrationAirtableTablesWithFields = z.infer; diff --git a/packages/types/v1/integration/googleSheet.ts b/packages/types/v1/integration/googleSheet.ts new file mode 100644 index 0000000000..aae5be36ed --- /dev/null +++ b/packages/types/v1/integration/googleSheet.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; +import { ZIntegrationBase, ZIntegrationBaseSurveyData } from "./sharedTypes"; + +export const ZGoogleCredential = z.object({ + scope: z.string(), + token_type: z.literal("Bearer"), + expiry_date: z.number(), + access_token: z.string(), + refresh_token: z.string(), +}); + +export type TGoogleCredential = z.infer; + +export const ZIntegrationGoogleSheetsConfigData = z + .object({ + spreadsheetId: z.string(), + spreadsheetName: z.string(), + }) + .merge(ZIntegrationBaseSurveyData); + +export type TIntegrationGoogleSheetsConfigData = z.infer; + +export const ZIntegrationGoogleSheetsConfig = z.object({ + key: ZGoogleCredential, + data: z.array(ZIntegrationGoogleSheetsConfigData), + email: z.string(), +}); + +export type TIntegrationGoogleSheetsConfig = z.infer; + +export const ZGoogleSheetIntegration = z.object({ + id: z.string(), + type: z.literal("googleSheets"), + environmentId: z.string(), + config: ZIntegrationGoogleSheetsConfig, +}); + +export const ZIntegrationGoogleSheets = ZIntegrationBase.extend({ + type: z.literal("googleSheets"), + config: ZIntegrationGoogleSheetsConfig, +}); + +export type TIntegrationGoogleSheets = z.infer; + +export const ZIntegrationGoogleSheetsInput = z.object({ + type: z.literal("googleSheets"), + config: ZIntegrationGoogleSheetsConfig, +}); + +export type TIntegrationGoogleSheetsInput = z.infer; + +export const ZIntegrationGoogleSheetsCredential = z.object({ + scope: z.string(), + token_type: z.literal("Bearer"), + expiry_date: z.number(), + access_token: z.string(), + refresh_token: z.string(), +}); + +export type TIntegrationGoogleSheetsCredential = z.infer; diff --git a/packages/types/v1/integration/index.ts b/packages/types/v1/integration/index.ts new file mode 100644 index 0000000000..149754af35 --- /dev/null +++ b/packages/types/v1/integration/index.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import { ZIntegrationAirtableConfig, ZIntegrationAirtableInput } from "./airtable"; +import { ZIntegrationGoogleSheetsConfig, ZIntegrationGoogleSheetsInput } from "./googleSheet"; +export * from "./sharedTypes"; + +export const ZIntegrationType = z.enum(["googleSheets", "airtable"]); + +export const ZIntegrationConfig = z.union([ZIntegrationGoogleSheetsConfig, ZIntegrationAirtableConfig]); + +export type TIntegrationConfig = z.infer; + +export const ZIntegrationBase = z.object({ + id: z.string(), + environmentId: z.string(), +}); + +export const ZIntegration = ZIntegrationBase.extend({ + type: ZIntegrationType, + config: ZIntegrationConfig, +}); + +export type TIntegration = z.infer; + +export const ZIntegrationBaseSurveyData = z.object({ + createdAt: z.date(), + questionIds: z.array(z.string()), + questions: z.string(), + surveyId: z.string(), + surveyName: z.string(), +}); + +export const ZIntegrationInput = z.union([ZIntegrationGoogleSheetsInput, ZIntegrationAirtableInput]); +export type TIntegrationInput = z.infer; + +export const ZIntegrationItem = z.object({ + name: z.string(), + id: z.string(), +}); +export type TIntegrationItem = z.infer; diff --git a/packages/types/v1/integration/sharedTypes.ts b/packages/types/v1/integration/sharedTypes.ts new file mode 100644 index 0000000000..e43fe255b3 --- /dev/null +++ b/packages/types/v1/integration/sharedTypes.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +export * from "./sharedTypes"; + +export const ZIntegrationBase = z.object({ + id: z.string(), + environmentId: z.string(), +}); + +export const ZIntegrationBaseSurveyData = z.object({ + createdAt: z.date(), + questionIds: z.array(z.string()), + questions: z.string(), + surveyId: z.string(), + surveyName: z.string(), +}); diff --git a/packages/types/v1/integrations.ts b/packages/types/v1/integrations.ts deleted file mode 100644 index b4d61a310b..0000000000 --- a/packages/types/v1/integrations.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { z } from "zod"; - -/* GOOGLE SHEETS CONFIGURATIONS */ -export const ZGoogleCredential = z.object({ - scope: z.string(), - token_type: z.literal("Bearer"), - expiry_date: z.number(), - access_token: z.string(), - refresh_token: z.string(), -}); -export type TGoogleCredential = z.infer; - -export const ZGoogleSpreadsheet = z.object({ - name: z.string(), - id: z.string(), -}); -export type TGoogleSpreadsheet = z.infer; - -export const ZGoogleSheetsConfigData = z.object({ - createdAt: z.date(), - questionIds: z.array(z.string()), - questions: z.string(), - spreadsheetId: z.string(), - spreadsheetName: z.string(), - surveyId: z.string(), - surveyName: z.string(), -}); -export type TGoogleSheetsConfigData = z.infer; - -const ZGoogleSheetsConfig = z.object({ - key: ZGoogleCredential, - data: z.array(ZGoogleSheetsConfigData), - email: z.string(), -}); -export type TGoogleSheetsConfig = z.infer; - -export const ZGoogleSheetIntegration = z.object({ - id: z.string(), - type: z.enum(["googleSheets"]), - environmentId: z.string(), - config: ZGoogleSheetsConfig, -}); -export type TGoogleSheetIntegration = z.infer; - -// Define a specific schema for integration configs -// When we add other configurations it will be z.union([ZGoogleSheetsConfig, ZSlackConfig, ...]) -export const ZIntegrationConfig = ZGoogleSheetsConfig; -export type TIntegrationConfig = z.infer; - -export const ZIntegrationType = z.enum(["googleSheets"]); -export type TIntegrationType = z.infer; - -export const ZIntegration = z.object({ - id: z.string(), - type: ZIntegrationType, - environmentId: z.string(), - config: ZIntegrationConfig, -}); -export type TIntegration = z.infer; - -export const ZIntegrationInput = z.object({ - type: ZIntegrationType, - config: ZIntegrationConfig, -}); -export type TIntegrationInput = z.infer; diff --git a/packages/ui/PasswordInput/index.tsx b/packages/ui/PasswordInput/index.tsx index 853d1fae59..c2f137295e 100644 --- a/packages/ui/PasswordInput/index.tsx +++ b/packages/ui/PasswordInput/index.tsx @@ -1,40 +1,46 @@ "use client"; -import { useState } from "react"; +import { forwardRef, useState } from "react"; import { cn } from "@formbricks/lib/cn"; import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid"; -export interface PasswordInputProps extends Omit, "type"> {} +export interface PasswordInputProps extends Omit, "type"> { + containerClassName?: string; +} -const PasswordInput = ({ className, ...rest }: PasswordInputProps) => { - const [showPassword, setShowPassword] = useState(false); +const PasswordInput = forwardRef( + ({ className, containerClassName, ...rest }, ref) => { + const [showPassword, setShowPassword] = useState(false); - const togglePasswordVisibility = () => { - setShowPassword((prevShowPassword) => !prevShowPassword); - }; + const togglePasswordVisibility = () => { + setShowPassword((prevShowPassword) => !prevShowPassword); + }; + return ( +
+ + +
+ ); + } +); - return ( -
- - -
- ); -}; +PasswordInput.displayName = "PasswordInput"; export { PasswordInput }; diff --git a/turbo.json b/turbo.json index e1f5c3d991..1fc57e98a2 100644 --- a/turbo.json +++ b/turbo.json @@ -110,6 +110,9 @@ "TELEMETRY_DISABLED", "VERCEL_URL", "WEBAPP_URL", + "AIR_TABLE_CLIENT_ID", + "AWS_ACCESS_KEY", + "AWS_SECRET_KEY", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION",