mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-03 17:09:58 -06:00
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:
committed by
GitHub
parent
892776c493
commit
21f393f402
@@ -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`
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -318,7 +318,7 @@ const CustomFilter = ({ environmentId, responses, survey, totalResponses }: Cust
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
80
apps/web/app/api/google-sheet/callback/route.ts
Normal file
80
apps/web/app/api/google-sheet/callback/route.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
43
apps/web/app/api/google-sheet/route.ts
Normal file
43
apps/web/app/api/google-sheet/route.ts
Normal 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 });
|
||||
}
|
||||
46
apps/web/app/api/integration/integrations.ts
Normal file
46
apps/web/app/api/integration/integrations.ts
Normal 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];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
apps/web/images/google-sheets-small.png
Normal file
BIN
apps/web/images/google-sheets-small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
79
apps/web/images/logo.svg
Normal file
79
apps/web/images/logo.svg
Normal 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 |
@@ -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
1
apps/web/token.json
Normal 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"}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
14
packages/lib/client/google.ts
Normal file
14
packages/lib/client/google.ts
Normal 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;
|
||||
};
|
||||
121
packages/lib/services/googleSheet.ts
Normal file
121
packages/lib/services/googleSheet.ts
Normal 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;
|
||||
};
|
||||
70
packages/lib/services/integrations.ts
Normal file
70
packages/lib/services/integrations.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
71
packages/types/v1/integrations.ts
Normal file
71
packages/types/v1/integrations.ts
Normal 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
21618
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user