feat: Add Google Sheets Integration (#735)

* added intro and surveySelect page

* added home page and  wrapper component

* added spreadsheet select

* added data write functionality and added integration schema model

* improved UX

* reworked UI

* added google sheet integration

* removed some unused code

* added user email

* UI tweaks

* fixed build issues and made added question to top of spreadsheets

* adds refreshSheets and added empty survey/spreadsheets text

* restored pnpm-lock

* added duplicate sheet warning

* move process.env to t3env

* move migration

* update docs link, add note to show that sheets integration is not configured

* Add simple docs page for google-sheets

* added session check

* restored pnpm-lock

* Merge branch 'main' of github.com:formbricks/formbricks into Integration/Google-sheet

* added google sheet  env variables to runtimeEnv

---------

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2023-09-19 18:59:11 +05:30
committed by GitHub
parent 892776c493
commit 21f393f402
33 changed files with 16409 additions and 6525 deletions

View File

@@ -0,0 +1,29 @@
import { Fence } from "@/components/shared/Fence";
import { Callout } from "@/components/shared/Callout";
import Image from "next/image";
export const meta = {
title: "n8n Setup",
description: "Wire up Formbricks with n8n and 350+ other apps",
};
#### Integrations
# Google Sheets
The Google Sheets integration allows you to automatically send responses to a Google Sheet of your choice.
<Note>
This feature is enabled by default in Formbricks Cloud but needs to be self-configured when running a
self-hosted version of Formbricks.
</Note>
## Setup in self-hosted Formbricks
Enabling the Google Sheets Integration in a self-hosted environment isn't easy and requires a setup using Google Cloud and changing the environment variables of your Formbricks instance.
The environment variables you need to set are:
- `GOOGLE_SHEETS_CLIENT_ID`
- `GOOGLE_SHEETS_CLIENT_SECRET`
- `GOOGLE_SHEETS_REDIRECT_URL`

View File

@@ -229,6 +229,7 @@ export const navigation: Array<NavGroup> = [
{ title: "Zapier", href: "/docs/integrations/zapier" },
{ title: "n8n", href: "/docs/integrations/n8n" },
{ title: "Make.com", href: "/docs/integrations/make" },
{ title: "Google Sheets", href: "/docs/integrations/google-sheets" },
],
},
{

View File

@@ -0,0 +1,326 @@
import { TSurvey } from "@formbricks/types/v1/surveys";
import {
TGoogleSheetIntegration,
TGoogleSheetsConfigData,
TGoogleSpreadsheet,
} from "@formbricks/types/v1/integrations";
import { Button, Checkbox, Label } from "@formbricks/ui";
import GoogleSheetLogo from "@/images/google-sheets-small.png";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import Image from "next/image";
import Modal from "@/components/shared/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";
interface AddWebhookModalProps {
environmentId: string;
open: boolean;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
spreadsheets: TGoogleSpreadsheet[];
googleSheetIntegration: TGoogleSheetIntegration;
selectedIntegration?: (TGoogleSheetsConfigData & { index: number }) | null;
}
export default function AddIntegrationModal({
environmentId,
surveys,
open,
setOpen,
spreadsheets,
googleSheetIntegration,
selectedIntegration,
}: AddWebhookModalProps) {
const { handleSubmit } = useForm();
const integrationData = {
spreadsheetId: "",
spreadsheetName: "",
surveyId: "",
surveyName: "",
questionIds: [""],
questions: "",
createdAt: new Date(),
};
const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
const [isLinkingSheet, setIsLinkingSheet] = useState(false);
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [selectedSpreadsheet, setSelectedSpreadsheet] = useState<any>(null);
const [isDeleting, setIsDeleting] = useState<any>(null);
const existingIntegrationData = googleSheetIntegration?.config?.data;
const googleSheetIntegrationData: Partial<TGoogleSheetIntegration> = {
type: "googleSheets",
config: {
key: googleSheetIntegration?.config?.key,
email: googleSheetIntegration.config.email,
data: existingIntegrationData || [],
},
};
useEffect(() => {
if (selectedSurvey) {
const questionIds = selectedSurvey.questions.map((question) => question.id);
if (!selectedIntegration) {
setSelectedQuestions(questionIds);
}
}
}, [selectedSurvey]);
useEffect(() => {
if (selectedIntegration) {
setSelectedSpreadsheet({
id: selectedIntegration.spreadsheetId,
name: selectedIntegration.spreadsheetName,
});
setSelectedSurvey(
surveys.find((survey) => {
return survey.id === selectedIntegration.surveyId;
})!
);
setSelectedQuestions(selectedIntegration.questionIds);
return;
}
resetForm();
}, [selectedIntegration]);
const linkSheet = async () => {
try {
if (!selectedSpreadsheet) {
throw new Error("Please select a spreadsheet");
}
if (!selectedSurvey) {
throw new Error("Please select a survey");
}
if (selectedQuestions.length === 0) {
throw new Error("Please select at least one question");
}
setIsLinkingSheet(true);
integrationData.spreadsheetId = selectedSpreadsheet.id;
integrationData.spreadsheetName = selectedSpreadsheet.name;
integrationData.surveyId = selectedSurvey.id;
integrationData.surveyName = selectedSurvey.name;
integrationData.questionIds = selectedQuestions;
integrationData.questions =
selectedQuestions.length === selectedSurvey?.questions.length
? "All questions"
: "Selected questions";
integrationData.createdAt = new Date();
if (selectedIntegration) {
// update action
googleSheetIntegrationData.config!.data[selectedIntegration.index] = integrationData;
} else {
// create action
googleSheetIntegrationData.config!.data.push(integrationData);
}
await upsertIntegrationAction(environmentId, googleSheetIntegrationData);
toast.success(`Integration ${selectedIntegration ? "updated" : "added"} successfully`);
resetForm();
setOpen(false);
} catch (e) {
toast.error(e.message);
} finally {
setIsLinkingSheet(false);
}
};
const handleCheckboxChange = (questionId: string) => {
setSelectedQuestions((prevValues) =>
prevValues.includes(questionId)
? prevValues.filter((value) => value !== questionId)
: [...prevValues, questionId]
);
};
const setOpenWithStates = (isOpen: boolean) => {
setOpen(isOpen);
};
const resetForm = () => {
setIsLinkingSheet(false);
setSelectedSpreadsheet("");
setSelectedSurvey(null);
};
const deleteLink = async () => {
googleSheetIntegrationData.config!.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
await upsertIntegrationAction(environmentId, googleSheetIntegrationData);
toast.success("Integration removed successfully");
setOpen(false);
} catch (error) {
toast.error(error.message);
} finally {
setIsDeleting(false);
}
};
const hasMatchingId = googleSheetIntegration.config.data.some((configData) => {
if (!selectedSpreadsheet) {
return false;
}
return configData.spreadsheetId === selectedSpreadsheet.id;
});
const DropdownSelector = ({ label, items, selectedItem, setSelectedItem, disabled }) => {
return (
<div className="col-span-1">
<Label htmlFor={label}>{label}</Label>
<div className="mt-1 flex">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
disabled={disabled ? disabled : false}
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300">
<span className="flex flex-1">
<span>{selectedItem ? selectedItem.name : `${label}`}</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-gray-500" />
</span>
</button>
</DropdownMenu.Trigger>
{!disabled && (
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-50 min-w-[220px] rounded-md bg-white text-sm text-slate-800 shadow-md"
align="start">
{items &&
items.map((item) => (
<DropdownMenu.Item
key={item.id}
className="flex cursor-pointer items-center p-3 hover:bg-gray-100 hover:outline-none data-[disabled]:cursor-default data-[disabled]:opacity-50"
onSelect={() => setSelectedItem(item)}>
{item.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
)}
</DropdownMenu.Root>
</div>
</div>
);
};
return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image className="w-12" src={GoogleSheetLogo} alt="Google Sheet logo" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Link Google Sheet</div>
<div className="text-sm text-slate-500">Sync responses with a Google Sheet</div>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(linkSheet)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div>
<div className="mb-4">
<DropdownSelector
label="Select Spreadsheet"
items={spreadsheets}
selectedItem={selectedSpreadsheet}
setSelectedItem={setSelectedSpreadsheet}
disabled={spreadsheets.length === 0}
/>
{selectedSpreadsheet && hasMatchingId && (
<p className="text-xs text-amber-700">
<strong>Warning:</strong> You have already connected one survey with this sheet. Your
data will be inconsistent
</p>
)}
<p className="m-1 text-xs text-slate-500">
{spreadsheets.length === 0 &&
"You have to create at least one spreadshseet to be able to setup this integration"}
</p>
</div>
<div>
<DropdownSelector
label="Select Survey"
items={surveys}
selectedItem={selectedSurvey}
setSelectedItem={setSelectedSurvey}
disabled={surveys.length === 0}
/>
<p className="m-1 text-xs text-slate-500">
{surveys.length === 0 &&
"You have to create a survey to be able to setup this integration"}
</p>
</div>
</div>
{selectedSurvey && (
<div>
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{selectedSurvey?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => {
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2">{question.headline}</span>
</label>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button
type="button"
variant="warn"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
Delete
</Button>
) : (
<Button
type="button"
variant="minimal"
onClick={() => {
setOpen(false);
resetForm();
}}>
Cancel
</Button>
)}
<Button variant="darkCTA" type="submit" loading={isLinkingSheet}>
{selectedIntegration ? "Update" : "Link Sheet"}
</Button>
</div>
</div>
</form>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import GoogleSheetLogo from "@/images/google-sheets-small.png";
import FormbricksLogo from "@/images/logo.svg";
import { authorize } from "@formbricks/lib/client/google";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { Button } from "@formbricks/ui";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
interface ConnectProps {
enabled: boolean;
environmentId: string;
}
export default function Connect({ enabled, environmentId }: ConnectProps) {
const [isConnecting, setIsConnecting] = useState(false);
const handleGoogleLogin = async () => {
setIsConnecting(true);
authorize(environmentId, WEBAPP_URL).then((url: string) => {
if (url) {
window.location.replace(url);
}
});
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex w-1/2 flex-col items-center justify-center rounded-lg bg-white p-8 shadow">
<div className="flex w-1/2 justify-center -space-x-4">
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-4 shadow-md">
<Image className="w-1/2" src={FormbricksLogo} alt="Formbricks Logo" />
</div>
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-4 shadow-md">
<Image className="w-1/2" src={GoogleSheetLogo} alt="Google Sheet logo" />
</div>
</div>
<p className="my-8">Sync responses directly with Google Sheets.</p>
{!enabled && (
<p className="mb-8 rounded border-gray-200 bg-gray-100 p-3 text-sm">
Google Sheets Integration is not configured in your instance of Formbricks.
<br />
Please follow the{" "}
<Link href="https://formbricks.com/docs/integrations/google-sheets" className="underline">
docs
</Link>{" "}
to configure it.
</p>
)}
<Button variant="darkCTA" loading={isConnecting} onClick={handleGoogleLogin} disabled={!enabled}>
Connect with Google
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import Home from "./Home";
import Connect from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/Connect";
import AddIntegrationModal from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/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";
interface GoogleSheetWrapperProps {
enabled: boolean;
environmentId: string;
surveys: TSurvey[];
spreadSheetArray: TGoogleSpreadsheet[];
googleSheetIntegration: TGoogleSheetIntegration | undefined;
}
export default function GoogleSheetWrapper({
enabled,
environmentId,
surveys,
spreadSheetArray,
googleSheetIntegration,
}: GoogleSheetWrapperProps) {
const [isConnected, setIsConnected] = useState(
googleSheetIntegration ? googleSheetIntegration.config?.key : false
);
const [spreadsheets, setSpreadsheets] = useState(spreadSheetArray);
const [isModalOpen, setModalOpen] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TGoogleSheetsConfigData & { index: number }) | null
>(null);
const refreshSheet = async () => {
const latestSpreadsheets = await refreshSheetAction(environmentId);
setSpreadsheets(latestSpreadsheets);
};
return (
<>
{isConnected && googleSheetIntegration ? (
<>
<AddIntegrationModal
environmentId={environmentId}
surveys={surveys}
open={isModalOpen}
setOpen={setModalOpen}
spreadsheets={spreadsheets}
googleSheetIntegration={googleSheetIntegration}
selectedIntegration={selectedIntegration}
/>
<Home
environmentId={environmentId}
googleSheetIntegration={googleSheetIntegration}
setOpenAddIntegrationModal={setModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
refreshSheet={refreshSheet}
/>
</>
) : (
<Connect enabled={enabled} environmentId={environmentId} />
)}
</>
);
}

View File

@@ -0,0 +1,130 @@
"use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { timeSince } from "@formbricks/lib/time";
import { TGoogleSheetIntegration, TGoogleSheetsConfigData } from "@formbricks/types/v1/integrations";
import { Button } from "@formbricks/ui";
import { useState } from "react";
import toast from "react-hot-toast";
interface HomeProps {
environmentId: string;
googleSheetIntegration: TGoogleSheetIntegration;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
setSelectedIntegration: (v: (TGoogleSheetsConfigData & { index: number }) | null) => void;
refreshSheet: () => void;
}
export default function Home({
environmentId,
googleSheetIntegration,
setOpenAddIntegrationModal,
setIsConnected,
setSelectedIntegration,
refreshSheet,
}: HomeProps) {
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const integrationArray = googleSheetIntegration
? googleSheetIntegration.config.data
? googleSheetIntegration.config.data
: []
: [];
const [isDeleting, setisDeleting] = useState(false);
const handleDeleteIntegration = async () => {
try {
setisDeleting(true);
await deleteIntegrationAction(googleSheetIntegration.id);
setIsConnected(false);
toast.success("Integration removed successfully");
} catch (error) {
toast.error(error.message);
} finally {
setisDeleting(false);
setIsDeleteIntegrationModalOpen(false);
}
};
const editIntegration = (index: number) => {
setSelectedIntegration({
...googleSheetIntegration.config.data[index],
index: index,
});
setOpenAddIntegrationModal(true);
};
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span
className="cursor-pointer text-slate-500"
onClick={() => {
setIsDeleteIntegrationModalOpen(true);
}}>
Connected with {googleSheetIntegration.config.email}
</span>
</div>
<Button
variant="darkCTA"
onClick={() => {
refreshSheet();
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
}}>
Link new Sheet
</Button>
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptySpaceFiller
type="table"
environmentId={environmentId}
noWidgetRequired={true}
emptyMessage="Your google sheet integrations will appear here as soon as you add them. ⏲️"
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
<div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 hidden text-center sm:block">Survey</div>
<div className="col-span-2 hidden text-center sm:block">Google Sheet Name</div>
<div className="col-span-2 hidden text-center sm:block">Questions</div>
<div className="col-span-2 hidden text-center sm:block">Updated At</div>
</div>
{integrationArray &&
integrationArray.map((data, index) => {
return (
<div
key={index}
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
<div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString())}</div>
</div>
);
})}
</div>
</div>
)}
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat="Google Connection"
onDelete={handleDeleteIntegration}
text="Are you sure? Your integrations will break."
isDeleting={isDeleting}
/>
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use server";
import { getSpreadSheets } from "@formbricks/lib/services/googleSheet";
import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/services/integrations";
import { TGoogleSheetIntegration } from "@formbricks/types/v1/integrations";
export async function upsertIntegrationAction(
environmentId: string,
integrationData: Partial<TGoogleSheetIntegration>
) {
return await createOrUpdateIntegration(environmentId, integrationData);
}
export async function deleteIntegrationAction(integrationId: string) {
return await deleteIntegration(integrationId);
}
export async function refreshSheetAction(environmentId: string) {
return await getSpreadSheets(environmentId);
}

View File

@@ -1,28 +1,24 @@
import GoBackButton from "@/components/shared/GoBackButton";
import { Button } from "@formbricks/ui";
import { Webhook } from "lucide-react";
export default function Loading() {
return (
<>
<div className="mt-6 p-6">
<GoBackButton />
<div className="mb-6 text-right">
<Button
variant="darkCTA"
className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-gray-200">
<Webhook className="mr-2 h-5 w-5 text-white" />
Loading
Link new sheet
</Button>
</div>
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-12 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<span className="sr-only">Edit</span>
<div className="col-span-3 pl-6 ">Webhook</div>
<div className="col-span-1 text-center">Source</div>
<div className="col-span-4 text-center">Surveys</div>
<div className="col-span-2 text-center ">Triggers</div>
<div className="col-span-2 text-center">Updated</div>
<div className="col-span-4 text-center ">Survey</div>
<div className="col-span-4 text-center">Google Sheet Name</div>
<div className="col-span-2 text-center ">Questions</div>
<div className="col-span-2 text-center">Updated At</div>
</div>
<div className="grid-cols-7">
{[...Array(3)].map((_, index) => (
@@ -59,6 +55,6 @@ export default function Loading() {
))}
</div>
</div>
</>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import GoogleSheetWrapper from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/GoogleSheetWrapper";
import GoBackButton from "@/components/shared/GoBackButton";
import { env } from "@/env.mjs";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getSpreadSheets } from "@formbricks/lib/services/googleSheet";
import { getIntegrations } from "@formbricks/lib/services/integrations";
import { getSurveys } from "@formbricks/lib/services/survey";
import { TGoogleSheetIntegration, TGoogleSpreadsheet } from "@formbricks/types/v1/integrations";
export default async function GoogleSheet({ params }) {
const enabled = !!(
env.GOOGLE_SHEETS_CLIENT_ID &&
env.GOOGLE_SHEETS_CLIENT_SECRET &&
env.GOOGLE_SHEETS_REDIRECT_URL
);
const surveys = await getSurveys(params.environmentId);
let spreadSheetArray: TGoogleSpreadsheet[] = [];
const integrations = await getIntegrations(params.environmentId);
const googleSheetIntegration: TGoogleSheetIntegration | undefined = integrations?.find(
(integration): integration is TGoogleSheetIntegration => integration.type === "googleSheets"
);
if (googleSheetIntegration && googleSheetIntegration.config.key) {
spreadSheetArray = await getSpreadSheets(params.environmentId);
}
return (
<>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
<div className="h-[75vh] w-full">
<GoogleSheetWrapper
enabled={enabled}
environmentId={params.environmentId}
surveys={surveys}
spreadSheetArray={spreadSheetArray}
googleSheetIntegration={googleSheetIntegration}
/>
</div>
</>
);
}

View File

@@ -1,6 +1,7 @@
import JsLogo from "@/images/jslogo.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";
@@ -42,6 +43,17 @@ export default function IntegrationsPage({ params }) {
description="Trigger Webhooks based on actions in your surveys"
icon={<Image src={WebhookLogo} alt="Webhook Logo" />}
/>
<Card
connectHref={`/environments/${params.environmentId}/integrations/google-sheets`}
connectText="Connect"
connectNewTab={false}
docsHref="https://formbricks.com/docs/integrations/google-sheets"
docsText="Docs"
docsNewTab={true}
label="Google Sheets"
description="Instantly populate your spreadsheets with survey data"
icon={<Image src={GoogleSheetsLogo} alt="Google sheets Logo" />}
/>
<Card
docsHref="https://formbricks.com/docs/integrations/n8n"
docsText="Docs"

View File

@@ -318,7 +318,7 @@ const CustomFilter = ({ environmentId, responses, survey, totalResponses }: Cust
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuContent>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {

View File

@@ -10,8 +10,8 @@ const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHO
if (typeof window !== "undefined") {
if (posthogEnabled) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_API_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
posthog.init(env.NEXT_PUBLIC_POSTHOG_API_KEY!, {
api_host: env.NEXT_PUBLIC_POSTHOG_API_HOST,
});
}
}

View File

@@ -1,12 +1,13 @@
import { env } from "@/env.mjs";
import { responses } from "@/lib/api/response";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { headers } from "next/headers";
export async function POST() {
const headersList = headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey || apiKey !== process.env.CRON_SECRET) {
if (!apiKey || apiKey !== env.CRON_SECRET) {
return responses.notAuthenticatedResponse();
}

View File

@@ -0,0 +1,80 @@
import { env } from "@/env.mjs";
import { prisma } from "@formbricks/database";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { google } from "googleapis";
import { NextRequest, NextResponse } from "next/server";
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");
if (!environmentId) {
return NextResponse.json({ error: "Invalid environmentId" });
}
if (code && typeof code !== "string") {
return NextResponse.json({ message: "`code` must be a string" }, { status: 400 });
}
const client_id = env.GOOGLE_SHEETS_CLIENT_ID;
const client_secret = env.GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = env.GOOGLE_SHEETS_REDIRECT_URL;
if (!client_id) return NextResponse.json({ Error: "Google client id is missing" }, { status: 400 });
if (!client_secret) return NextResponse.json({ Error: "Google client secret is missing" }, { status: 400 });
if (!redirect_uri) return NextResponse.json({ Error: "Google redirect url is missing" }, { status: 400 });
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
let key;
let userEmail;
if (code) {
const token = await oAuth2Client.getToken(code);
key = token.res?.data;
// Set credentials using the provided token
oAuth2Client.setCredentials({
access_token: key.access_token,
});
// Fetch user's email
const oauth2 = google.oauth2({
auth: oAuth2Client,
version: "v2",
});
const userInfo = await oauth2.userinfo.get();
userEmail = userInfo.data.email;
}
const googleSheetIntegration = {
type: "googleSheets" as "googleSheets",
environment: environmentId,
config: {
key,
data: [],
email: userEmail,
},
};
const result = await prisma.integration.upsert({
where: {
type_environmentId: {
environmentId,
type: "googleSheets",
},
},
update: {
...googleSheetIntegration,
environment: { connect: { id: environmentId } },
},
create: {
...googleSheetIntegration,
environment: { connect: { id: environmentId } },
},
});
if (result) {
return NextResponse.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/google-sheets`);
}
}

View File

@@ -0,0 +1,43 @@
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
import { env } from "@/env.mjs";
import { google } from "googleapis";
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
const scopes = [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/userinfo.email",
];
export async function GET(req: NextRequest) {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ Error: "Invalid session" }, { status: 400 });
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user, environmentId);
if (!canUserAccessEnvironment) {
return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 });
}
const client_id = env.GOOGLE_SHEETS_CLIENT_ID;
const client_secret = env.GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = env.GOOGLE_SHEETS_REDIRECT_URL;
if (!client_id) return NextResponse.json({ Error: "Google client id is missing" }, { status: 400 });
if (!client_secret) return NextResponse.json({ Error: "Google client secret is missing" }, { status: 400 });
if (!redirect_uri) return NextResponse.json({ Error: "Google redirect url is missing" }, { status: 400 });
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: scopes,
prompt: "consent",
state: environmentId!,
});
return NextResponse.json({ authUrl }, { status: 200 });
}

View File

@@ -0,0 +1,46 @@
import { writeData } from "@formbricks/lib/services/googleSheet";
import { getSurvey } from "@formbricks/lib/services/survey";
import { TGoogleSheetIntegration, TIntegration } from "@formbricks/types/v1/integrations";
import { TPipelineInput } from "@formbricks/types/v1/pipelines";
export async function handleIntegrations(integrations: TIntegration[], data: TPipelineInput) {
for (const integration of integrations) {
switch (integration.type) {
case "googleSheets":
await handleGoogleSheetsIntegration(integration as TGoogleSheetIntegration, data);
break;
}
}
}
async function handleGoogleSheetsIntegration(integration: TGoogleSheetIntegration, 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 writeData(integration.config.key, element.spreadsheetId, values);
}
}
}
}
async function extractResponses(data: TPipelineInput, questionIds: string[]): Promise<string[][]> {
const responses: string[] = [];
const questions: string[] = [];
const survey = await getSurvey(data.surveyId);
for (const questionId of questionIds) {
const responseValue = data.response.data[questionId];
if (responseValue !== undefined) {
responses.push(Array.isArray(responseValue) ? responseValue.join(",") : String(responseValue));
} else {
responses.push("");
}
const question = survey?.questions.find((q) => q.id === questionId);
questions.push(question?.headline || "");
}
return [responses, questions];
}

View File

@@ -9,6 +9,7 @@ import { NotificationSettings } from "@formbricks/types/users";
import { ZPipelineInput } from "@formbricks/types/v1/pipelines";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { handleIntegrations } from "../integration/integrations";
export async function POST(request: Request) {
// check authentication with x-api-key header and CRON_SECRET env variable
@@ -90,6 +91,15 @@ export async function POST(request: Request) {
},
},
});
const integrations = await prisma.integration.findMany({
where: {
environmentId,
},
});
if (integrations.length > 0) {
handleIntegrations(integrations, inputValidation.data);
}
// filter all users that have email notifications enabled for this survey
const usersWithNotifications = users.filter((user) => {
const notificationSettings: NotificationSettings | null = user.notificationSettings;

View File

@@ -3,10 +3,18 @@
import { BackIcon } from "@formbricks/ui";
import { useRouter } from "next/navigation";
export default function GoBackButton() {
export default function GoBackButton({ url }: { url?: string }) {
const router = useRouter();
return (
<button className="inline-flex pt-5 text-sm text-slate-500" onClick={() => router.back()}>
<button
className="inline-flex pt-5 text-sm text-slate-500"
onClick={() => {
if (url) {
router.push(url);
return;
}
router.back();
}}>
<BackIcon className="mr-2 h-5 w-5" />
Back
</button>

View File

@@ -25,6 +25,9 @@ export const env = createEnv({
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
CRON_SECRET: z.string().optional(),
GOOGLE_SHEETS_CLIENT_ID: z.string().optional(),
GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(),
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
},
/*
* Environment variables available on the client (and server).
@@ -92,6 +95,9 @@ export const env = createEnv({
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
CRON_SECRET: process.env.CRON_SECRET,
GOOGLE_SHEETS_CLIENT_ID: process.env.GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET: process.env.GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
NEXT_PUBLIC_WEBAPP_URL: process.env.NEXT_PUBLIC_WEBAPP_URL,
NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED: process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED,
NEXT_PUBLIC_PASSWORD_RESET_DISABLED: process.env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED,

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

79
apps/web/images/logo.svg Normal file
View File

@@ -0,0 +1,79 @@
<svg width="101" height="150" viewBox="0 0 101 150" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2627_5881)">
<path d="M0 101.547H40.4528V122C40.4528 133.046 31.4985 142 20.4528 142H20C8.9543 142 0 133.046 0 122V101.547Z" fill="url(#paint0_linear_2627_5881)"/>
<path d="M0 54.7737H81.1321C92.1778 54.7737 101.132 63.728 101.132 74.7737V75.2265C101.132 86.2722 92.1778 95.2265 81.1321 95.2265H0V54.7737Z" fill="url(#paint1_linear_2627_5881)"/>
<path d="M0 28C0 16.9543 8.95431 8 20 8H81.1321C92.1778 8 101.132 16.9543 101.132 28V28.4528C101.132 39.4985 92.1778 48.4528 81.1321 48.4528H0V28Z" fill="url(#paint2_linear_2627_5881)"/>
<mask id="mask0_2627_5881" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="8" width="102" height="134">
<path d="M0 101.547H40.4528V122C40.4528 133.046 31.4985 142 20.4528 142H20C8.9543 142 0 133.046 0 122V101.547Z" fill="url(#paint3_linear_2627_5881)"/>
<path d="M0 54.7737H81.1321C92.1778 54.7737 101.132 63.728 101.132 74.7737V75.2265C101.132 86.2722 92.1778 95.2265 81.1321 95.2265H0V54.7737Z" fill="url(#paint4_linear_2627_5881)"/>
<path d="M0 28C0 16.9543 8.95431 8 20 8H81.1321C92.1778 8 101.132 16.9543 101.132 28V28.4528C101.132 39.4985 92.1778 48.4528 81.1321 48.4528H0V28Z" fill="url(#paint5_linear_2627_5881)"/>
</mask>
<g mask="url(#mask0_2627_5881)">
<g filter="url(#filter0_d_2627_5881)">
<mask id="mask1_2627_5881" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="8" width="102" height="134">
<path d="M0 101.547H40.4528V122C40.4528 133.046 31.4985 142 20.4528 142H20C8.9543 142 0 133.046 0 122V101.547Z" fill="black" fill-opacity="0.1"/>
<path d="M0 28C0 16.9543 8.95431 8 20 8H81.1321C92.1778 8 101.132 16.9543 101.132 28V28.4528C101.132 39.4985 92.1778 48.4528 81.1321 48.4528H0V28Z" fill="black" fill-opacity="0.1"/>
<path d="M0 54.7737H81.1321C92.1778 54.7737 101.132 63.728 101.132 74.7737V75.2265C101.132 86.2722 92.1778 95.2265 81.1321 95.2265H0V54.7737Z" fill="black" fill-opacity="0.1"/>
</mask>
<g mask="url(#mask1_2627_5881)">
<path d="M2.12216 -26.8434C17.9685 -42.3091 58.1507 -26.8434 58.1507 -26.8434H2.12216C-1.76989 -23.0449 -4.19388 -17.3804 -4.19388 -9.16251C-4.19388 32.5141 40.9522 47.6695 40.9522 76.7169C40.9522 105.152 -2.3106 122.695 -4.13455 161.333H58.1507C58.1507 161.333 -4.19388 204.273 -4.19388 163.859C-4.19388 163.007 -4.17382 162.165 -4.13455 161.333H-31.604L-26.2295 -26.8434H2.12216Z" fill="black" fill-opacity="0.1"/>
</g>
</g>
<g filter="url(#filter1_f_2627_5881)">
<circle cx="-12.6414" cy="124.302" r="37.9245" fill="#00C4B8"/>
</g>
<g filter="url(#filter2_f_2627_5881)">
<circle cx="-12.6414" cy="28.2265" r="37.9245" fill="#00C4B8"/>
</g>
</g>
</g>
<defs>
<filter id="filter0_d_2627_5881" x="-2" y="-4" width="82.1506" height="158" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="10"/>
<feGaussianBlur stdDeviation="6"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2627_5881"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2627_5881" result="shape"/>
</filter>
<filter id="filter1_f_2627_5881" x="-70.5659" y="66.3774" width="115.849" height="115.849" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="10" result="effect1_foregroundBlur_2627_5881"/>
</filter>
<filter id="filter2_f_2627_5881" x="-70.5659" y="-29.698" width="115.849" height="115.849" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="10" result="effect1_foregroundBlur_2627_5881"/>
</filter>
<linearGradient id="paint0_linear_2627_5881" x1="40.6287" y1="121.041" x2="-0.003482" y2="121.205" gradientUnits="userSpaceOnUse">
<stop stop-color="#00E6CA"/>
<stop offset="1" stop-color="#00C4B8"/>
</linearGradient>
<linearGradient id="paint1_linear_2627_5881" x1="101.572" y1="74.2673" x2="1.27605e-08" y2="75.2932" gradientUnits="userSpaceOnUse">
<stop stop-color="#00E6CA"/>
<stop offset="1" stop-color="#00C4B8"/>
</linearGradient>
<linearGradient id="paint2_linear_2627_5881" x1="101.572" y1="27.4936" x2="1.27605e-08" y2="28.5195" gradientUnits="userSpaceOnUse">
<stop stop-color="#00E6CA"/>
<stop offset="1" stop-color="#00C4B8"/>
</linearGradient>
<linearGradient id="paint3_linear_2627_5881" x1="40.6287" y1="121.041" x2="-0.003482" y2="121.205" gradientUnits="userSpaceOnUse">
<stop stop-color="#00FFE1"/>
<stop offset="1" stop-color="#01E0C6"/>
</linearGradient>
<linearGradient id="paint4_linear_2627_5881" x1="101.572" y1="74.2673" x2="1.27605e-08" y2="75.2932" gradientUnits="userSpaceOnUse">
<stop stop-color="#00FFE1"/>
<stop offset="1" stop-color="#01E0C6"/>
</linearGradient>
<linearGradient id="paint5_linear_2627_5881" x1="101.572" y1="27.4936" x2="1.27605e-08" y2="28.5195" gradientUnits="userSpaceOnUse">
<stop stop-color="#00FFE1"/>
<stop offset="1" stop-color="#01E0C6"/>
</linearGradient>
<clipPath id="clip0_2627_5881">
<rect width="101" height="150" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -19,7 +19,7 @@
"@formbricks/surveys": "workspace:*",
"@formbricks/types": "workspace:*",
"@formbricks/ui": "workspace:*",
"@headlessui/react": "^1.7.17",
"@headlessui/react": "^1.7.16",
"@heroicons/react": "^2.0.18",
"@json2csv/node": "^7.0.3",
"@paralleldrive/cuid2": "^2.2.2",
@@ -28,8 +28,10 @@
"@sentry/nextjs": "^7.68.0",
"@t3-oss/env-nextjs": "^0.6.1",
"bcryptjs": "^2.4.3",
"eslint-config-next": "^13.4.19",
"jsonwebtoken": "^9.0.2",
"encoding": "^0.1.13",
"eslint-config-next": "^13.4.12",
"googleapis": "^126.0.1",
"jsonwebtoken": "^9.0.1",
"lodash": "^4.17.21",
"lucide-react": "^0.276.0",
"next": "13.4.19",

1
apps/web/token.json Normal file
View File

@@ -0,0 +1 @@
{"type":"authorized_user","client_id":"199115468636-d50rh01o2g3u48qnd8u3u35mmt1ja7hd.apps.googleusercontent.com","client_secret":"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","refresh_token":"1//0gdoxIs9Q_4BBCgYIARAAGBASNwF-L9IrDzCGm3KUfkvyvZUgYcxIZaip-skwm9Q86Kz86IZ1WYijEnWx4ZiGBm2YtrqI1hTsMg8"}

View File

@@ -1,4 +1,5 @@
import { TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
import { TIntegrationConfig } from "@formbricks/types/v1/integrations";
import { TResponsePersonAttributes, TResponseData, TResponseMeta } from "@formbricks/types/v1/responses";
import {
TSurveyClosedMessage,
@@ -12,6 +13,7 @@ declare global {
namespace PrismaJson {
export type EventProperties = { [key: string]: string };
export type EventClassNoCodeConfig = TActionClassNoCodeConfig;
export type IntegrationConfig = TIntegrationConfig;
export type ResponseData = TResponseData;
export type ResponseMeta = TResponseMeta;
export type ResponsePersonAttributes = TResponsePersonAttributes;

View File

@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "IntegrationType" AS ENUM ('googleSheets');
-- CreateTable
CREATE TABLE "Integration" (
"id" TEXT NOT NULL,
"type" "IntegrationType" NOT NULL,
"environmentId" TEXT NOT NULL,
"config" JSONB NOT NULL,
CONSTRAINT "Integration_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Integration_type_environmentId_key" ON "Integration"("type", "environmentId");
-- AddForeignKey
ALTER TABLE "Integration" ADD CONSTRAINT "Integration_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -302,6 +302,22 @@ enum EnvironmentType {
development
}
enum IntegrationType {
googleSheets
}
model Integration {
id String @id @default(cuid())
type IntegrationType
environmentId String
/// @zod.custom(imports.ZIntegrationConfig)
/// [IntegrationConfig]
config Json
environment Environment @relation(fields: [environmentId], references: [id])
@@unique([type, environmentId])
}
model Environment {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
@@ -317,6 +333,7 @@ model Environment {
apiKeys ApiKey[]
webhooks Webhook[]
tags Tag[]
integration Integration[]
}
enum WidgetPlacement {

View File

@@ -2,6 +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 { ZResponseData, ZResponsePersonAttributes, ZResponseMeta } from "@formbricks/types/v1/responses";

View File

@@ -0,0 +1,14 @@
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/google-sheet`, {
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;
};

View File

@@ -0,0 +1,121 @@
import { prisma } from "@formbricks/database";
import { Prisma } from "@prisma/client";
import { DatabaseError } from "@formbricks/types/v1/errors";
import { cache } from "react";
import {
TGoogleCredential,
TGoogleSheetIntegration,
TGoogleSpreadsheet,
} from "@formbricks/types/v1/integrations";
const { google } = require("googleapis");
async function fetchSpreadsheets(auth: any) {
const authClient = authorize(auth);
const service = google.drive({ version: "v3", auth: authClient });
try {
const res = await service.files.list({
q: "mimeType='application/vnd.google-apps.spreadsheet' AND trashed=false",
fields: "nextPageToken, files(id, name)",
});
return res.data.files;
} catch (err) {
throw err;
}
}
export const getGoogleSheetIntegration = cache(
async (environmentId: string): Promise<TGoogleSheetIntegration | null> => {
try {
const result = await prisma.integration.findUnique({
where: {
type_environmentId: {
environmentId,
type: "googleSheets",
},
},
});
// Type Guard
if (result && isGoogleSheetIntegration(result)) {
return result as TGoogleSheetIntegration; // Explicit casting
}
return null;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
}
);
function isGoogleSheetIntegration(integration: any): integration is TGoogleSheetIntegration {
return integration.type === "googleSheets";
}
export const getSpreadSheets = async (environmentId: string): Promise<TGoogleSpreadsheet[]> => {
let spreadsheets: TGoogleSpreadsheet[] = [];
try {
const googleIntegration = await getGoogleSheetIntegration(environmentId);
if (googleIntegration && googleIntegration.config?.key) {
spreadsheets = await fetchSpreadsheets(googleIntegration.config?.key);
}
return spreadsheets;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
export async function writeData(credentials: TGoogleCredential, spreadsheetId: string, values: string[][]) {
try {
const authClient = authorize(credentials);
const sheets = google.sheets({ version: "v4", auth: authClient });
const responses = { values: [values[0]] };
const question = { values: [values[1]] };
sheets.spreadsheets.values.update(
{
spreadsheetId: spreadsheetId,
range: "A1",
valueInputOption: "RAW",
resource: question,
},
(err: Error) => {
if (err) {
throw new Error(`Error while appending data: ${err.message}`);
} else {
}
}
);
sheets.spreadsheets.values.append(
{
spreadsheetId: spreadsheetId,
range: "A2",
valueInputOption: "RAW",
resource: responses,
},
(err: Error) => {
if (err) {
throw new Error(`Error while appending data: ${err.message}`);
} else {
}
}
);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
}
const authorize = (credentials: any) => {
const client_id = process.env.GOOGLE_APP_CLIENT_ID;
const client_secret = process.env.GOOGLE_APP_CLIENT_SECRET;
const redirect_uri = process.env.GOOGLE_APP_REDIRECT_URL;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
oAuth2Client.setCredentials(credentials);
return oAuth2Client;
};

View File

@@ -0,0 +1,70 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { Prisma } from "@prisma/client";
import { DatabaseError } from "@formbricks/types/v1/errors";
import { TIntegration } from "@formbricks/types/v1/integrations";
import { cache } from "react";
export async function createOrUpdateIntegration(
environmentId: string,
integrationData: any
): Promise<TIntegration> {
try {
const integration = await prisma.integration.upsert({
where: {
type_environmentId: {
environmentId,
type: integrationData.type,
},
},
update: {
...integrationData,
environment: { connect: { id: environmentId } },
},
create: {
...integrationData,
environment: { connect: { id: environmentId } },
},
});
return integration;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.log(error);
throw new DatabaseError("Database operation failed");
}
throw error;
}
}
export const getIntegrations = cache(async (environmentId: string): Promise<TIntegration[]> => {
try {
const result = await prisma.integration.findMany({
where: {
environmentId,
},
});
return result;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
});
export const deleteIntegration = async (integrationId: string): Promise<void> => {
try {
await prisma.integration.delete({
where: {
id: integrationId,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};

View File

@@ -0,0 +1,71 @@
import { z } from "zod";
import { ZEnvironment } from "./environment";
// Define a specific schema for googleSheets config
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 const ZGoogleSpreadsheet = z.object({
name: z.string(),
id: z.string(),
});
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(),
});
const ZGoogleSheetsConfig = z.object({
key: ZGoogleCredential,
data: z.array(ZGoogleSheetsConfigData),
email: z.string(),
});
// Define a dynamic schema for config based on integration type
const ZPlaceholderConfig = z.object({
placeholder: z.string(),
});
export const ZIntegrationConfig = z.union([ZGoogleSheetsConfig, ZPlaceholderConfig]);
export const ZIntegration = z.object({
id: z.string(),
type: z.enum(["googleSheets", "placeholder"]),
environmentId: z.string(),
config: ZIntegrationConfig,
});
export const ZGoogleSheetIntegration = z.object({
id: z.string(),
type: z.enum(["googleSheets"]),
environmentId: z.string(),
config: ZGoogleSheetsConfig,
});
export const ZPlaceHolderIntegration = z.object({
id: z.string(),
type: z.enum(["placeholder"]),
environmentId: z.string(),
config: ZPlaceholderConfig,
environment: ZEnvironment,
});
export type TIntegration = z.infer<typeof ZIntegration>;
export type TIntegrationConfig = z.infer<typeof ZIntegrationConfig>;
export type TGoogleCredential = z.infer<typeof ZGoogleCredential>;
export type TGoogleSpreadsheet = z.infer<typeof ZGoogleSpreadsheet>;
export type TGoogleSheetsConfig = z.infer<typeof ZGoogleSheetsConfig>;
export type TGoogleSheetsConfigData = z.infer<typeof ZGoogleSheetsConfigData>;
export type TGoogleSheetIntegration = z.infer<typeof ZGoogleSheetIntegration>;
export type TPlaceHolderIntegration = z.infer<typeof ZPlaceHolderIntegration>;

21618
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,9 @@
"GITHUB_SECRET",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"GOOGLE_SHEETS_CLIENT_ID",
"GOOGLE_SHEETS_CLIENT_SECRET",
"GOOGLE_SHEETS_REDIRECT_URL",
"HEROKU_APP_NAME",
"INSTANCE_ID",
"INTERNAL_SECRET",