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) => (
+
+ {header}
+
+ ))}
+
+
+ {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 (
+
+
+
+
+
+
+
+
+
Link Airbase Table
+
Sync responses with a Airbase table
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
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: ,
+ 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 @@
[](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",