synced with main

This commit is contained in:
Piyush Gupta
2023-10-03 22:50:11 +05:30
46 changed files with 720 additions and 74 deletions

View File

@@ -101,3 +101,7 @@ GOOGLE_CLIENT_SECRET=
# Cron Secret
CRON_SECRET=
# Encryption key
# You can use: `openssl rand -base64 16` to generate one
FORMBRICKS_ENCRYPTION_KEY=

View File

@@ -104,4 +104,8 @@ NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
# Cron Secret
CRON_SECRET=
*/
*/
# Encryption key
# You can use: `openssl rand -base64 16` to generate one
FORMBRICKS_ENCRYPTION_KEY=

View File

@@ -57,7 +57,7 @@ These variables must also be provided at runtime.
| Variable | Description | Required | Default |
| --------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- |
| WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
| SURVEY_BASE_URL | Base URL of the link surveys. | required | `http://localhost:3000/s/` |
| SURVEY_BASE_URL | Base URL of the link surveys. | required | `http://localhost:3000/s/` |
| DATABASE_URL | Database URL with credentials. | required | `postgresql://postgres:postgres@postgres:5432/formbricks?schema=public` |
| PRISMA_GENERATE_DATAPROXY | Enables a dedicated connection pool for Prisma using Prisma Data Proxy. Uncomment to enable. | optional | |
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) |

View File

@@ -164,7 +164,9 @@ export async function duplicateSurveyAction(environmentId: string, surveyId: str
surveyClosedMessage: existingSurvey.surveyClosedMessage
? JSON.parse(JSON.stringify(existingSurvey.surveyClosedMessage))
: prismaClient.JsonNull,
singleUse: existingSurvey.singleUse
? JSON.parse(JSON.stringify(existingSurvey.singleUse))
: prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail
? JSON.parse(JSON.stringify(existingSurvey.verifyEmail))
: prismaClient.JsonNull,
@@ -295,6 +297,7 @@ export async function copyToOtherEnvironmentAction(
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
},
});

View File

@@ -3,6 +3,7 @@ import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import Link from "next/link";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { TEnvironment } from "@formbricks/types/v1/environment";
export default function ResponseFeed({
@@ -63,6 +64,7 @@ export default function ResponseFeed({
/>
</div>
</div>
<div className="mt-3 space-y-3">
{response.survey.questions.map((question) => (
<div key={question.id}>
@@ -75,6 +77,18 @@ export default function ResponseFeed({
</div>
))}
</div>
<div className="flex w-full justify-end">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<p className="text-sm text-slate-500">{response.singleUseId}</p>
</TooltipTrigger>
<TooltipContent>
<p className="text-sm text-slate-500">Single Use Id</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</div>

View File

@@ -36,6 +36,7 @@ interface SurveyDropDownMenuProps {
environment: TEnvironment;
otherEnvironment: TEnvironment;
surveyBaseUrl: string;
singleUseId?: string;
}
export default function SurveyDropDownMenu({
@@ -44,6 +45,7 @@ export default function SurveyDropDownMenu({
environment,
otherEnvironment,
surveyBaseUrl,
singleUseId,
}: SurveyDropDownMenuProps) {
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
@@ -155,7 +157,11 @@ export default function SurveyDropDownMenu({
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/s/${survey.id}?preview=true`}
href={
singleUseId
? `/s/${survey.id}?suId=${singleUseId}&preview=true`
: `/s/${survey.id}?preview=true`
}
target="_blank">
<EyeIcon className="mr-2 h-4 w-4" />
Preview Survey
@@ -165,8 +171,11 @@ export default function SurveyDropDownMenu({
<button
className="flex w-full items-center"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
navigator.clipboard.writeText(
singleUseId ? `${surveyUrl}?suId=${singleUseId}` : surveyUrl
);
toast.success("Copied link to clipboard");
router.refresh();
}}>
<LinkIcon className="mr-2 h-4 w-4" />
Copy Link

View File

@@ -10,6 +10,7 @@ import type { TEnvironment } from "@formbricks/types/v1/environment";
import { Badge } from "@formbricks/ui";
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { generateSurveySingleUseId } from "@/lib/singleUseSurveys";
export default async function SurveysList({ environmentId }: { environmentId: string }) {
const product = await getProductByEnvironmentId(environmentId);
@@ -45,57 +46,63 @@ export default async function SurveysList({ environmentId }: { environmentId: st
</Link>
{surveys
.sort((a, b) => b.updatedAt?.getTime() - a.updatedAt?.getTime())
.map((survey) => (
<li key={survey.id} className="relative col-span-1 h-56">
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
<div className="px-6 py-4">
<Badge
StartIcon={survey.type === "link" ? LinkIcon : ComputerDesktopIcon}
startIconClassName="mr-2"
text={
survey.type === "link"
? "Link Survey"
: survey.type === "web"
? "In-Product Survey"
: ""
.map((survey) => {
const isSingleUse = survey.singleUse?.enabled ?? false;
const isEncrypted = survey.singleUse?.isEncrypted ?? false;
const singleUseId = isSingleUse ? generateSurveySingleUseId(isEncrypted) : undefined;
return (
<li key={survey.id} className="relative col-span-1 h-56">
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
<div className="px-6 py-4">
<Badge
StartIcon={survey.type === "link" ? LinkIcon : ComputerDesktopIcon}
startIconClassName="mr-2"
text={
survey.type === "link"
? "Link Survey"
: survey.type === "web"
? "In-Product Survey"
: ""
}
type="gray"
size={"tiny"}
className="font-base"></Badge>
<p className="my-2 line-clamp-3 text-lg">{survey.name}</p>
</div>
<Link
href={
survey.status === "draft"
? `/environments/${environmentId}/surveys/${survey.id}/edit`
: `/environments/${environmentId}/surveys/${survey.id}/summary`
}
type="gray"
size={"tiny"}
className="font-base"></Badge>
<p className="my-2 line-clamp-3 text-lg">{survey.name}</p>
</div>
<Link
href={
survey.status === "draft"
? `/environments/${environmentId}/surveys/${survey.id}/edit`
: `/environments/${environmentId}/surveys/${survey.id}/summary`
}
className="absolute h-full w-full"></Link>
<div className="divide-y divide-slate-100">
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
<div className="flex items-center">
{survey.status !== "draft" && (
<>
<SurveyStatusIndicator status={survey.status} tooltip environment={environment} />
</>
)}
{survey.status === "draft" && (
<span className="text-xs italic text-slate-400">Draft</span>
)}
className="absolute h-full w-full"></Link>
<div className="divide-y divide-slate-100">
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
<div className="flex items-center">
{survey.status !== "draft" && (
<>
<SurveyStatusIndicator status={survey.status} tooltip environment={environment} />
</>
)}
{survey.status === "draft" && (
<span className="text-xs italic text-slate-400">Draft</span>
)}
</div>
<SurveyDropDownMenu
survey={survey}
key={`surveys-${survey.id}`}
environmentId={environmentId}
environment={environment}
otherEnvironment={otherEnvironment!}
surveyBaseUrl={SURVEY_BASE_URL}
singleUseId={singleUseId}
/>
</div>
<SurveyDropDownMenu
survey={survey}
key={`surveys-${survey.id}`}
environmentId={environmentId}
environment={environment}
otherEnvironment={otherEnvironment!}
surveyBaseUrl={SURVEY_BASE_URL}
/>
</div>
</div>
</div>
</li>
))}
</li>
);
})}
</ul>
<UsageAttributesUpdater numSurveys={surveys.length} />
</>

View File

@@ -147,6 +147,11 @@ export default function SingleResponse({
)}
<div className="flex space-x-4 text-sm">
{data.singleUseId && (
<span className="flex items-center rounded-full bg-slate-100 px-3 text-slate-600">
{data.singleUseId}
</span>
)}
{data.finished && (
<span className="flex items-center rounded-full bg-slate-100 px-3 text-slate-600">
Completed <CheckCircleIcon className="ml-1 h-5 w-5 text-green-400" />

View File

@@ -7,12 +7,14 @@ import { useState } from "react";
import clsx from "clsx";
import { TProduct } from "@formbricks/types/v1/product";
import ShareEmbedSurvey from "./ShareEmbedSurvey";
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
interface LinkSurveyShareButtonProps {
survey: TSurvey;
className?: string;
surveyBaseUrl: string;
product: TProduct;
singleUseIds?: string[];
}
export default function LinkSurveyShareButton({
@@ -20,8 +22,10 @@ export default function LinkSurveyShareButton({
className,
surveyBaseUrl,
product,
singleUseIds,
}: LinkSurveyShareButtonProps) {
const [showLinkModal, setShowLinkModal] = useState(false);
const isSingleUse = survey.singleUse?.enabled ?? false;
return (
<>
@@ -34,8 +38,14 @@ export default function LinkSurveyShareButton({
onClick={() => setShowLinkModal(true)}>
<ShareIcon className="h-5 w-5" />
</Button>
{showLinkModal && (
{showLinkModal && isSingleUse && singleUseIds ? (
<LinkSingleUseSurveyModal
survey={survey}
open={showLinkModal}
setOpen={setShowLinkModal}
singleUseIds={singleUseIds}
/>
) : (
<ShareEmbedSurvey
survey={survey}
open={showLinkModal}

View File

@@ -0,0 +1,122 @@
"use client";
import { Button, Dialog, DialogContent } from "@formbricks/ui";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/outline";
import { CheckCircleIcon, DocumentDuplicateIcon, EyeIcon } from "@heroicons/react/24/solid";
import { useRef, useState } from "react";
import toast from "react-hot-toast";
import { truncateMiddle } from "@/lib/utils";
import { cn } from "@formbricks/lib/cn";
import { useRouter } from "next/navigation";
interface LinkSingleUseSurveyModalProps {
survey: TSurvey;
open: boolean;
setOpen: (open: boolean) => void;
singleUseIds: string[];
}
export default function LinkSingleUseSurveyModal({
survey,
open,
setOpen,
singleUseIds,
}: LinkSingleUseSurveyModalProps) {
const defaultSurveyUrl = `${window.location.protocol}//${window.location.host}/s/${survey.id}`;
const [selectedSingleUseIds, setSelectedSingleIds] = useState<number[]>([]);
const linkTextRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const handleLinkOnClick = (index: number) => {
setSelectedSingleIds([...selectedSingleUseIds, index]);
const surveyUrl = `${defaultSurveyUrl}?suId=${singleUseIds[index]}`;
navigator.clipboard.writeText(surveyUrl);
toast.success("URL copied to clipboard!");
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="bottom-0 max-w-sm bg-white p-4 sm:bottom-auto sm:max-w-xl sm:p-6">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-100">
<CheckIcon className="h-6 w-6 text-teal-600" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg font-semibold leading-6 text-gray-900">Your survey is ready!</h3>
<div className="mt-4">
<p className="text-sm text-gray-500">
Here are 5 single use links to let people answer your survey:
</p>
<div ref={linkTextRef}>
{singleUseIds.map((singleUseId, index) => {
const isSelected = selectedSingleUseIds.includes(index);
return (
<div
key={singleUseId}
className={cn(
"row relative mt-3 flex max-w-full cursor-pointer items-center justify-between overflow-auto rounded-lg border border-slate-300 bg-slate-50 px-8 py-4 text-left text-slate-800 transition-all duration-200 ease-in-out hover:border-slate-500",
isSelected && "border-slate-200 text-slate-400 hover:border-slate-200"
)}
onClick={() => {
if (!isSelected) {
handleLinkOnClick(index);
}
}}>
<span>{truncateMiddle(`${defaultSurveyUrl}?suId=${singleUseId}`, 48)}</span>
{isSelected ? (
<CheckCircleIcon className="ml-4 h-4 w-4" />
) : (
<DocumentDuplicateIcon className="ml-4 h-4 w-4" />
)}
</div>
);
})}
</div>
</div>
<div className="mt-4 flex flex-col justify-center gap-2 sm:flex-row sm:justify-end">
<Button
variant="secondary"
title="Generate new single-use survey link"
aria-label="Generate new single-use survey link"
className="flex justify-center"
onClick={() => {
router.refresh();
setSelectedSingleIds([]);
toast.success("New survey links generated!");
}}
EndIcon={ArrowPathIcon}>
Regenerate
</Button>
<Button
variant="secondary"
onClick={() => {
setSelectedSingleIds(Array.from(singleUseIds.keys()));
const allSurveyUrls = singleUseIds
.map((singleUseId) => `${defaultSurveyUrl}?suId=${singleUseId}`)
.join("\n");
navigator.clipboard.writeText(allSurveyUrls);
toast.success("All URLs copied to clipboard!");
}}
title="Copy all survey links to clipboard"
aria-label="Copy all survey links to clipboard"
className="flex justify-center"
EndIcon={DocumentDuplicateIcon}>
Copy 5 URLs
</Button>
<Button
variant="darkCTA"
title="Preview survey"
aria-label="Preview survey"
className="flex justify-center"
href={`${defaultSurveyUrl}?suId=${singleUseIds[0]}preview=true`}
target="_blank"
EndIcon={EyeIcon}>
Preview
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import ShareEmbedSurvey from "./ShareEmbedSurvey";
import { TProduct } from "@formbricks/types/v1/product";
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
import { TEnvironment } from "@formbricks/types/v1/environment";
interface SummaryMetadataProps {
@@ -14,6 +15,7 @@ interface SummaryMetadataProps {
survey: TSurvey;
surveyBaseUrl: string;
product: TProduct;
singleUseIds?: string[];
}
export default function SuccessMessage({
@@ -21,7 +23,10 @@ export default function SuccessMessage({
survey,
surveyBaseUrl,
product,
singleUseIds,
}: SummaryMetadataProps) {
const isSingleUse = survey.singleUse?.enabled ?? false;
const searchParams = useSearchParams();
const [showLinkModal, setShowLinkModal] = useState(false);
const [confetti, setConfetti] = useState(false);
@@ -52,7 +57,14 @@ export default function SuccessMessage({
return (
<>
{showLinkModal && (
{showLinkModal && isSingleUse && singleUseIds ? (
<LinkSingleUseSurveyModal
survey={survey}
open={showLinkModal}
setOpen={setShowLinkModal}
singleUseIds={singleUseIds}
/>
) : (
<ShareEmbedSurvey
survey={survey}
open={showLinkModal}

View File

@@ -22,6 +22,7 @@ interface SummaryPageProps {
surveyId: string;
responses: TResponse[];
surveyBaseUrl: string;
singleUseIds?: string[];
product: TProduct;
environmentTags: TTag[];
}
@@ -32,6 +33,7 @@ const SummaryPage = ({
surveyId,
responses,
surveyBaseUrl,
singleUseIds,
product,
environmentTags,
}: SummaryPageProps) => {
@@ -56,6 +58,7 @@ const SummaryPage = ({
survey={survey}
surveyId={surveyId}
surveyBaseUrl={surveyBaseUrl}
singleUseIds={singleUseIds}
product={product}
/>
<CustomFilter

View File

@@ -9,16 +9,28 @@ import { getEnvironment } from "@formbricks/lib/services/environment";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getServerSession } from "next-auth";
import { generateSurveySingleUseId } from "@/lib/singleUseSurveys";
const generateSingleUseIds = (isEncrypted: boolean) => {
return Array(5)
.fill(null)
.map(() => {
return generateSurveySingleUseId(isEncrypted);
});
};
export default async function Page({ params }) {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
}
const [{ responses, survey }, environment] = await Promise.all([
getAnalysisData(params.surveyId, params.environmentId),
getEnvironment(params.environmentId),
]);
const isSingleUseSurvey = survey.singleUse?.enabled ?? false;
const singleUseIds = generateSingleUseIds(survey.singleUse?.isEncrypted ?? false);
if (!environment) {
throw new Error("Environment not found");
}
@@ -38,6 +50,7 @@ export default async function Page({ params }) {
survey={survey}
surveyId={params.surveyId}
surveyBaseUrl={SURVEY_BASE_URL}
singleUseIds={isSingleUseSurvey ? singleUseIds : undefined}
product={product}
environmentTags={tags}
/>

View File

@@ -31,8 +31,16 @@ interface SummaryHeaderProps {
survey: TSurvey;
surveyBaseUrl: string;
product: TProduct;
singleUseIds?: string[];
}
const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }: SummaryHeaderProps) => {
const SummaryHeader = ({
surveyId,
environment,
survey,
surveyBaseUrl,
product,
singleUseIds,
}: SummaryHeaderProps) => {
const router = useRouter();
const isCloseOnDateEnabled = survey.closeOnDate !== null;
@@ -47,7 +55,12 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
</div>
<div className="hidden justify-end gap-x-1.5 sm:flex">
{survey.type === "link" && (
<LinkSurveyShareButton survey={survey} surveyBaseUrl={surveyBaseUrl} product={product} />
<LinkSurveyShareButton
survey={survey}
surveyBaseUrl={surveyBaseUrl}
singleUseIds={singleUseIds}
product={product}
/>
)}
{(environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? (
<SurveyStatusDropdown environment={environment} survey={survey} />
@@ -75,6 +88,7 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
survey={survey}
surveyBaseUrl={surveyBaseUrl}
product={product}
singleUseIds={singleUseIds}
/>
<DropdownMenuSeparator />
</>
@@ -157,6 +171,7 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
survey={survey}
surveyBaseUrl={surveyBaseUrl}
product={product}
singleUseIds={singleUseIds}
/>
</div>
);

View File

@@ -1,7 +1,17 @@
"use client";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { AdvancedOptionToggle, DatePicker, Input, Label } from "@formbricks/ui";
import {
AdvancedOptionToggle,
DatePicker,
Input,
Label,
Switch,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react";
@@ -10,9 +20,14 @@ import toast from "react-hot-toast";
interface ResponseOptionsCardProps {
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
isEncryptionKeySet: boolean;
}
export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: ResponseOptionsCardProps) {
export default function ResponseOptionsCard({
localSurvey,
setLocalSurvey,
isEncryptionKeySet,
}: ResponseOptionsCardProps) {
const [open, setOpen] = useState(false);
const autoComplete = localSurvey.autoComplete !== null;
const [redirectToggle, setRedirectToggle] = useState(false);
@@ -27,6 +42,12 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
subheading: "This free & open-source survey has been closed",
});
const [singleUseMessage, setSingleUseMessage] = useState({
heading: "The survey has already been answered.",
subheading: "You can only use this link once.",
});
const [singleUseEncryption, setSingleUseEncryption] = useState(isEncryptionKeySet);
const [verifyEmailSurveyDetails, setVerifyEmailSurveyDetails] = useState({
name: "",
subheading: "",
@@ -104,6 +125,53 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
setLocalSurvey({ ...localSurvey, surveyClosedMessage: message });
};
const handleSingleUseSurveyToggle = () => {
if (!localSurvey.singleUse?.enabled) {
setLocalSurvey({
...localSurvey,
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: singleUseEncryption },
});
} else {
setLocalSurvey({ ...localSurvey, singleUse: { enabled: false, isEncrypted: false } });
}
};
const handleSingleUseSurveyMessageChange = ({
heading,
subheading,
}: {
heading?: string;
subheading?: string;
}) => {
const message = {
heading: heading ?? singleUseMessage.heading,
subheading: subheading ?? singleUseMessage.subheading,
};
const localSurveySingleUseEnabled = localSurvey.singleUse?.enabled ?? false;
setSingleUseMessage(message);
setLocalSurvey({
...localSurvey,
singleUse: { enabled: localSurveySingleUseEnabled, ...message, isEncrypted: singleUseEncryption },
});
};
const hangleSingleUseEncryptionToggle = () => {
if (!singleUseEncryption) {
setSingleUseEncryption(true);
setLocalSurvey({
...localSurvey,
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: true },
});
} else {
setSingleUseEncryption(false);
setLocalSurvey({
...localSurvey,
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: false },
});
}
};
const handleVerifyEmailSurveyDetailsChange = ({
name,
subheading,
@@ -134,6 +202,14 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
setSurveyClosedMessageToggle(true);
}
if (localSurvey.singleUse?.enabled) {
setSingleUseMessage({
heading: localSurvey.singleUse.heading ?? singleUseMessage.heading,
subheading: localSurvey.singleUse.subheading ?? singleUseMessage.subheading,
});
setSingleUseEncryption(localSurvey.singleUse.isEncrypted);
}
if (localSurvey.verifyEmail) {
setVerifyEmailSurveyDetails({
name: localSurvey.verifyEmail.name!,
@@ -302,6 +378,81 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
</div>
</AdvancedOptionToggle>
{/* Single User Survey Options */}
<AdvancedOptionToggle
htmlId="singleUserSurveyOptions"
isChecked={!!localSurvey.singleUse?.enabled}
onToggle={handleSingleUseSurveyToggle}
title="Single-Use Survey Links"
description="Allow only 1 response per survey link."
childBorder={true}>
<div className="flex w-full items-center space-x-1 p-4 pb-4">
<div className="w-full cursor-pointer items-center bg-slate-50">
<div className="row mb-2 flex cursor-default items-center space-x-2">
<Label htmlFor="howItWorks">How it works</Label>
</div>
<ul className="mb-3 ml-4 cursor-default list-inside list-disc space-y-1">
<li className="text-sm text-slate-600">
Blocks survey if the survey URL has no Single Use Id (suId).
</li>
<li className="text-sm text-slate-600">
Blocks survey if a submission with the Single Use Id (suId) in the URL exists already.
</li>
</ul>
<Label htmlFor="headline">&lsquo;Link Used&rsquo; Message</Label>
<Input
autoFocus
id="heading"
className="mb-4 mt-2 bg-white"
name="heading"
defaultValue={singleUseMessage.heading}
onChange={(e) => handleSingleUseSurveyMessageChange({ heading: e.target.value })}
/>
<Label htmlFor="headline">Subheading</Label>
<Input
className="mb-4 mt-2 bg-white"
id="subheading"
name="subheading"
defaultValue={singleUseMessage.subheading}
onChange={(e) => handleSingleUseSurveyMessageChange({ subheading: e.target.value })}
/>
<Label htmlFor="headline">URL Encryption</Label>
<div>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="mt-2 flex items-center space-x-1 ">
<Switch
id="encryption-switch"
checked={singleUseEncryption}
onCheckedChange={hangleSingleUseEncryptionToggle}
disabled={!isEncryptionKeySet}
/>
<Label htmlFor="encryption-label">
<div className="ml-2">
<p className="text-sm font-normal text-slate-600">
Enable encryption of Single Use Id (suId) in survey URL.
</p>
</div>
</Label>
</div>
</TooltipTrigger>
{!isEncryptionKeySet && (
<TooltipContent side={"top"}>
<p className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
FORMBRICKS_ENCRYPTION_KEY needs to be set to enable this feature.
</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</AdvancedOptionToggle>
{/* Verify Email Section */}
<AdvancedOptionToggle
htmlId="verifyEmailBeforeSubmission"
isChecked={verifyEmailToggle}

View File

@@ -14,6 +14,7 @@ interface SettingsViewProps {
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
isEncryptionKeySet: boolean;
}
export default function SettingsView({
@@ -22,6 +23,7 @@ export default function SettingsView({
setLocalSurvey,
actionClasses,
attributeClasses,
isEncryptionKeySet,
}: SettingsViewProps) {
return (
<div className="mt-12 space-y-3 p-5">
@@ -41,7 +43,11 @@ export default function SettingsView({
actionClasses={actionClasses}
/>
<ResponseOptionsCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
<ResponseOptionsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
isEncryptionKeySet={isEncryptionKeySet}
/>
<RecontactOptionsCard
localSurvey={localSurvey}

View File

@@ -20,6 +20,7 @@ interface SurveyEditorProps {
environment: TEnvironment;
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
isEncryptionKeySet: boolean;
}
export default function SurveyEditor({
@@ -28,6 +29,7 @@ export default function SurveyEditor({
environment,
actionClasses,
attributeClasses,
isEncryptionKeySet,
}: SurveyEditorProps): JSX.Element {
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
@@ -88,6 +90,7 @@ export default function SurveyEditor({
setLocalSurvey={setLocalSurvey}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
isEncryptionKeySet={isEncryptionKeySet}
/>
)}
</main>

View File

@@ -155,6 +155,7 @@ export default function SurveyMenuBar({
} else {
router.push(`/environments/${environment.id}/surveys`);
}
router.refresh();
}
} catch (e) {
console.error(e);

View File

@@ -1,6 +1,6 @@
export const revalidate = REVALIDATION_INTERVAL;
import React from "react";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { FORMBRICKS_ENCRYPTION_KEY, REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import SurveyEditor from "./SurveyEditor";
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
@@ -17,6 +17,7 @@ export default async function SurveysEditPage({ params }) {
getActionClasses(params.environmentId),
getAttributeClasses(params.environmentId),
]);
const isEncryptionKeySet = !!FORMBRICKS_ENCRYPTION_KEY;
if (!survey || !environment || !actionClasses || !attributeClasses || !product) {
return <ErrorComponent />;
}
@@ -29,6 +30,7 @@ export default async function SurveysEditPage({ params }) {
environment={environment}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
isEncryptionKeySet={isEncryptionKeySet}
/>
</>
);

View File

@@ -2062,4 +2062,5 @@ export const minimalSurvey: TSurvey = {
surveyClosedMessage: {
enabled: false,
},
singleUse: null,
};

View File

@@ -164,6 +164,7 @@ export const getSurveys = async (environmentId: string, person: TPerson): Promis
})
.map((survey) => ({
...survey,
singleUse: survey.singleUse ? JSON.parse(JSON.stringify(survey.singleUse)) : null,
triggers: survey.triggers.map((trigger) => trigger.eventClass),
attributeFilters: survey.attributeFilters.map((af) => ({
...af,

View File

@@ -12,7 +12,8 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import VerifyEmail from "@/app/s/[surveyId]/VerifyEmail";
import { getPrefillResponseData } from "@/app/s/[surveyId]/prefilling";
import { TResponseData } from "@formbricks/types/v1/responses";
import { TResponse, TResponseData } from "@formbricks/types/v1/responses";
import SurveyLinkUsed from "@/app/s/[surveyId]/SurveyLinkUsed";
interface LinkSurveyProps {
survey: TSurvey;
@@ -20,6 +21,8 @@ interface LinkSurveyProps {
personId?: string;
emailVerificationStatus?: string;
prefillAnswer?: string;
singleUseId?: string;
singleUseResponse?: TResponse;
webAppUrl: string;
}
@@ -29,11 +32,15 @@ export default function LinkSurvey({
personId,
emailVerificationStatus,
prefillAnswer,
singleUseId,
singleUseResponse,
webAppUrl,
}: LinkSurveyProps) {
const responseId = singleUseResponse?.id;
const searchParams = useSearchParams();
const isPreview = searchParams?.get("preview") === "true";
const [surveyState, setSurveyState] = useState(new SurveyState(survey.id));
// pass in the responseId if the survey is a single use survey, ensures survey state is updated with the responseId
const [surveyState, setSurveyState] = useState(new SurveyState(survey.id, singleUseId, responseId));
const [activeQuestionId, setActiveQuestionId] = useState<string>(survey.questions[0].id);
const prefillResponseData: TResponseData | undefined = prefillAnswer
? getPrefillResponseData(survey.questions[0], survey, prefillAnswer)
@@ -56,6 +63,13 @@ export default function LinkSurvey({
[personId, webAppUrl]
);
const [autoFocus, setAutofocus] = useState(false);
const hasFinishedSingleUseResponse = useMemo(() => {
if (singleUseResponse && singleUseResponse.finished) {
return true;
}
return false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Not in an iframe, enable autofocus on input fields.
useEffect(() => {
@@ -68,6 +82,10 @@ export default function LinkSurvey({
responseQueue.updateSurveyState(surveyState);
}, [responseQueue, surveyState]);
if (!surveyState.isResponseFinished() && hasFinishedSingleUseResponse) {
return <SurveyLinkUsed singleUseMessage={survey.singleUse} />;
}
if (emailVerificationStatus && emailVerificationStatus !== "verified") {
if (emailVerificationStatus === "fishy") {
return <VerifyEmail survey={survey} isErrorComponent={true} />;

View File

@@ -1,6 +1,6 @@
import { TSurveyClosedMessage } from "@formbricks/types/v1/surveys";
import { Button } from "@formbricks/ui";
import { CheckCircleIcon, PauseCircleIcon } from "@heroicons/react/24/solid";
import { CheckCircleIcon, PauseCircleIcon, QuestionMarkCircleIcon } from "@heroicons/react/24/solid";
import Image from "next/image";
import Link from "next/link";
import footerLogo from "./footerlogo.svg";
@@ -9,17 +9,19 @@ const SurveyInactive = ({
status,
surveyClosedMessage,
}: {
status: "paused" | "completed";
status: "paused" | "completed" | "link invalid";
surveyClosedMessage?: TSurveyClosedMessage | null;
}) => {
const icons = {
paused: <PauseCircleIcon className="h-20 w-20" />,
completed: <CheckCircleIcon className="h-20 w-20" />,
"link invalid": <QuestionMarkCircleIcon className="h-20 w-20" />,
};
const descriptions = {
paused: "This free & open-source survey is temporarily paused.",
completed: "This free & open-source survey has been closed.",
"link invalid": "This survey can only be taken by invitation.",
};
return (
@@ -31,12 +33,11 @@ const SurveyInactive = ({
{status === "completed" && surveyClosedMessage ? surveyClosedMessage.heading : `Survey ${status}.`}
</h1>
<p className="text-lg leading-10 text-gray-500">
{" "}
{status === "completed" && surveyClosedMessage
? surveyClosedMessage.subheading
: descriptions[status]}
</p>
{!(status === "completed" && surveyClosedMessage) && (
{!(status === "completed" && surveyClosedMessage) && status !== "link invalid" && (
<Button variant="darkCTA" className="mt-2" href="https://formbricks.com">
Create your own
</Button>

View File

@@ -0,0 +1,35 @@
import { SurveySingleUse } from "@formbricks/types/surveys";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import Image from "next/image";
import Link from "next/link";
import footerLogo from "./footerlogo.svg";
type SurveyLinkUsedProps = {
singleUseMessage: Omit<SurveySingleUse, "enabled"> | null;
};
const SurveyLinkUsed = ({ singleUseMessage }: SurveyLinkUsedProps) => {
const defaultHeading = "The survey has already been answered.";
const defaultSubheading = "You can only use this link once.";
return (
<div className="flex min-h-screen flex-col items-center justify-between bg-gradient-to-tr from-slate-200 to-slate-50 py-8 text-center">
<div></div>
<div className="flex flex-col items-center space-y-3 text-slate-300">
<CheckCircleIcon className="h-20 w-20" />
<h1 className="text-4xl font-bold text-slate-800">
{!!singleUseMessage?.heading ? singleUseMessage?.heading : defaultHeading}
</h1>
<p className="text-lg leading-10 text-gray-500">
{!!singleUseMessage?.subheading ? singleUseMessage?.subheading : defaultSubheading}
</p>
</div>
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
</div>
);
};
export default SurveyLinkUsed;

View File

@@ -9,9 +9,26 @@ import { getSurvey } from "@formbricks/lib/services/survey";
import { getEmailVerificationStatus } from "./helpers";
import { checkValidity } from "@/app/s/[surveyId]/prefilling";
import { notFound } from "next/navigation";
import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
import { TResponse } from "@formbricks/types/v1/responses";
import { validateSurveySingleUseId } from "@/lib/singleUseSurveys";
export default async function LinkSurveyPage({ params, searchParams }) {
interface LinkSurveyPageProps {
params: {
surveyId: string;
};
searchParams: {
suId?: string;
userId?: string;
verify?: string;
};
}
export default async function LinkSurveyPage({ params, searchParams }: LinkSurveyPageProps) {
const survey = await getSurvey(params.surveyId);
const suId = searchParams.suId;
const isSingleUseSurvey = survey?.singleUse?.enabled;
const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted;
if (!survey || survey.type !== "link" || survey.status === "draft") {
notFound();
@@ -30,14 +47,41 @@ export default async function LinkSurveyPage({ params, searchParams }) {
);
}
let singleUseId: string | undefined = undefined;
if (isSingleUseSurvey) {
// check if the single use id is present for single use surveys
if (!suId) {
return <SurveyInactive status="link invalid" />;
}
// if encryption is enabled, validate the single use id
let validatedSingleUseId: string | undefined = undefined;
if (isSingleUseSurveyEncrypted) {
validatedSingleUseId = validateSurveySingleUseId(suId);
if (!validatedSingleUseId) {
return <SurveyInactive status="link invalid" />;
}
}
// if encryption is disabled, use the suId as is
singleUseId = validatedSingleUseId ?? suId;
}
let singleUseResponse: TResponse | undefined = undefined;
if (isSingleUseSurvey) {
singleUseResponse = (await getResponseBySingleUseId(survey.id, singleUseId)) ?? undefined;
}
// verify email: Check if the survey requires email verification
let emailVerificationStatus;
let emailVerificationStatus: string | undefined = undefined;
if (survey.verifyEmail) {
const token =
searchParams && Object.keys(searchParams).length !== 0 && searchParams.hasOwnProperty("verify")
? searchParams.verify
: undefined;
emailVerificationStatus = await getEmailVerificationStatus(survey.id, token);
if (token) {
emailVerificationStatus = await getEmailVerificationStatus(survey.id, token);
}
}
// get product and person
@@ -59,6 +103,8 @@ export default async function LinkSurveyPage({ params, searchParams }) {
personId={person?.id}
emailVerificationStatus={emailVerificationStatus}
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
singleUseResponse={singleUseResponse ? singleUseResponse : undefined}
webAppUrl={WEBAPP_URL}
/>
);

View File

@@ -70,6 +70,7 @@ export const env = createEnv({
NEXT_PUBLIC_POSTHOG_API_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_API_HOST: z.string().optional(),
NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),
FORMBRICKS_ENCRYPTION_KEY: z.string().length(24).or(z.string().length(0)).optional(),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
@@ -114,6 +115,7 @@ export const env = createEnv({
IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD,
NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY,
NEXT_PUBLIC_POSTHOG_API_HOST: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
FORMBRICKS_ENCRYPTION_KEY: process.env.FORMBRICKS_ENCRYPTION_KEY,
VERCEL_URL: process.env.VERCEL_URL,
SURVEY_BASE_URL: process.env.SURVEY_BASE_URL,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,

View File

@@ -0,0 +1,33 @@
import { FORMBRICKS_ENCRYPTION_KEY } from "@formbricks/lib/constants";
import { decryptAES128, encryptAES128 } from "@formbricks/lib/crypto";
import cuid2 from "@paralleldrive/cuid2";
// generate encrypted single use id for the survey
export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
const cuid = cuid2.createId();
if (!isEncrypted) {
return cuid;
}
if (!FORMBRICKS_ENCRYPTION_KEY) {
throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
}
const encryptedCuid = encryptAES128(FORMBRICKS_ENCRYPTION_KEY, cuid);
return encryptedCuid;
};
// validate the survey single use id
export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => {
if (!FORMBRICKS_ENCRYPTION_KEY) {
throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
}
try {
const decryptedCuid = decryptAES128(FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId);
if (cuid2.isCuid(decryptedCuid)) {
return decryptedCuid;
} else {
return undefined;
}
} catch (error) {
return undefined;
}
};

View File

@@ -64,6 +64,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
data: true,
meta: true,
personAttributes: true,
singleUseId: true,
person: {
select: {
id: true,

View File

@@ -109,6 +109,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
data: true,
meta: true,
personAttributes: true,
singleUseId: true,
person: {
select: {
id: true,

View File

@@ -146,6 +146,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
},
});

View File

@@ -66,6 +66,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
},
});

View File

@@ -98,6 +98,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
body.surveyClosedMessage = prismaClient.JsonNull;
}
if (!body.singleUse) {
body.singleUse = prismaClient.JsonNull;
}
if (!body.verifyEmail) {
body.verifyEmail = prismaClient.JsonNull;
}
@@ -230,6 +234,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
data.surveyClosedMessage = prismaClient.JsonNull;
}
if (data.singleUse === null) {
data.singleUse = prismaClient.JsonNull;
}
if (data.verifyEmail === null) {
data.verifyEmail = prismaClient.JsonNull;
}

View File

@@ -4,6 +4,7 @@ import { TResponsePersonAttributes, TResponseData, TResponseMeta } from "@formbr
import {
TSurveyClosedMessage,
TSurveyQuestions,
TSurveySingleUse,
TSurveyThankYouCard,
TSurveyVerifyEmail,
} from "@formbricks/types/v1/surveys";
@@ -20,6 +21,7 @@ declare global {
export type SurveyQuestions = TSurveyQuestions;
export type SurveyThankYouCard = TSurveyThankYouCard;
export type SurveyClosedMessage = TSurveyClosedMessage;
export type SurveySingleUse = TSurveySingleUse;
export type SurveyVerifyEmail = TSurveyVerifyEmail;
export type UserNotificationSettings = TUserNotificationSettings;
}

View File

@@ -0,0 +1,14 @@
/*
Warnings:
- A unique constraint covering the columns `[surveyId,singleUseId]` on the table `Response` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Response" ADD COLUMN "singleUseId" TEXT;
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "singleUse" JSONB DEFAULT '{"enabled": false, "isEncrypted": true}';
-- CreateIndex
CREATE UNIQUE INDEX "Response_surveyId_singleUseId_key" ON "Response"("surveyId", "singleUseId");

View File

@@ -113,6 +113,10 @@ model Response {
/// @zod.custom(imports.ZResponsePersonAttributes)
/// [ResponsePersonAttributes]
personAttributes Json?
// singleUseId, used to prevent multiple responses
singleUseId String?
@@unique([surveyId, singleUseId])
}
model ResponseNote {
@@ -245,6 +249,9 @@ model Survey {
/// @zod.custom(imports.ZSurveyClosedMessage)
/// [SurveyClosedMessage]
surveyClosedMessage Json?
/// @zod.custom(imports.ZSurveySingleUse)
/// [SurveySingleUse]
singleUse Json? @default("{\"enabled\": false, \"isEncrypted\": true}")
/// @zod.custom(imports.ZSurveyVerifyEmail)
/// [SurveyVerifyEmail]
verifyEmail Json?

View File

@@ -11,6 +11,7 @@ export {
ZSurveyThankYouCard,
ZSurveyClosedMessage,
ZSurveyVerifyEmail,
ZSurveySingleUse,
} from "@formbricks/types/v1/surveys";
export { ZUserNotificationSettings } from "@formbricks/types/v1/users";

View File

@@ -15,6 +15,7 @@ export const SURVEY_BASE_URL = env.SURVEY_BASE_URL ? env.SURVEY_BASE_URL + "/" :
// Other
export const INTERNAL_SECRET = process.env.INTERNAL_SECRET || "";
export const FORMBRICKS_ENCRYPTION_KEY = env.FORMBRICKS_ENCRYPTION_KEY || undefined;
export const CRON_SECRET = env.CRON_SECRET;
export const DEFAULT_BRAND_COLOR = "#64748b";

View File

@@ -1,3 +1,18 @@
import { createHash } from "crypto";
import { createHash, createCipheriv, createDecipheriv } from "crypto";
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
// create an aes128 encryption function
export const encryptAES128 = (encryptionKey: string, data: string): string => {
const cipher = createCipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
let encrypted = cipher.update(data, "utf-8", "hex");
encrypted += cipher.final("hex");
return encrypted;
};
// create an aes128 decryption function
export const decryptAES128 = (encryptionKey: string, data: string): string => {
const cipher = createDecipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
let decrypted = cipher.update(data, "hex", "utf-8");
decrypted += cipher.final("utf-8");
return decrypted;
};

View File

@@ -12,6 +12,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/v1/error
import { TPerson } from "@formbricks/types/v1/people";
import { TTag } from "@formbricks/types/v1/tags";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { cache } from "react";
import { getPerson, transformPrismaPerson } from "../services/person";
import { captureTelemetry } from "../telemetry";
@@ -28,6 +29,7 @@ const responseSelection = {
data: true,
meta: true,
personAttributes: true,
singleUseId: true,
person: {
select: {
id: true,
@@ -115,6 +117,41 @@ export const getResponsesByPersonId = async (personId: string): Promise<Array<TR
}
};
export const getResponseBySingleUseId = cache(
async (surveyId: string, singleUseId?: string): Promise<TResponse | null> => {
validateInputs([surveyId, ZId], [singleUseId, z.string()]);
try {
if (!singleUseId) {
return null;
}
const responsePrisma = await prisma.response.findUnique({
where: {
surveyId_singleUseId: { surveyId, singleUseId },
},
select: responseSelection,
});
if (!responsePrisma) {
return null;
}
const response: TResponse = {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
}
);
export const createResponse = async (responseInput: Partial<TResponseInput>): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput.partial()]);
captureTelemetry("response created");
@@ -143,6 +180,7 @@ export const createResponse = async (responseInput: Partial<TResponseInput>): Pr
personAttributes: person?.attributes,
}),
...(responseInput.meta && ({ meta: responseInput?.meta } as Prisma.JsonObject)),
singleUseId: responseInput.singleUseId,
},
select: responseSelection,
});

View File

@@ -72,7 +72,12 @@ export class ResponseQueue {
await updateResponse(responseUpdate, this.surveyState.responseId, this.config.apiHost);
} else {
const response = await createResponse(
{ ...responseUpdate, surveyId: this.surveyState.surveyId, personId: this.config.personId || null },
{
...responseUpdate,
surveyId: this.surveyState.surveyId,
personId: this.config.personId || null,
singleUseId: this.surveyState.singleUseId || null,
},
this.config.apiHost
);
if (this.surveyState.displayId) {

View File

@@ -50,6 +50,7 @@ export const selectSurvey = {
verifyEmail: true,
redirectUrl: true,
surveyClosedMessage: true,
singleUse: true,
triggers: {
select: {
eventClass: {

View File

@@ -5,9 +5,12 @@ export class SurveyState {
displayId: string | null = null;
surveyId: string;
responseAcc: TResponseUpdate = { finished: false, data: {} };
singleUseId: string | null;
constructor(surveyId: string) {
constructor(surveyId: string, singleUseId?: string, responseId?: string) {
this.surveyId = surveyId;
this.singleUseId = singleUseId ?? null;
this.responseId = responseId ?? null;
}
/**
@@ -22,7 +25,11 @@ export class SurveyState {
* Get a copy of the current state
*/
copy() {
const copyInstance = new SurveyState(this.surveyId);
const copyInstance = new SurveyState(
this.surveyId,
this.singleUseId ?? undefined,
this.responseId ?? undefined
);
copyInstance.responseId = this.responseId;
copyInstance.responseAcc = this.responseAcc;
return copyInstance;

View File

@@ -11,6 +11,12 @@ export interface SurveyClosedMessage {
subheading?: string;
}
export interface SurveySingleUse {
enabled: boolean;
heading?: string;
subheading?: string;
}
export interface VerifyEmail {
name?: string;
subheading?: string;
@@ -39,6 +45,7 @@ export interface Survey {
surveyClosedMessage: SurveyClosedMessage | null;
verifyEmail: VerifyEmail | null;
closeOnDate: Date | null;
singleUse: SurveySingleUse | null;
_count: { responses: number | null } | null;
}

View File

@@ -53,6 +53,7 @@ export const ZResponse = z.object({
notes: z.array(ZResponseNote),
tags: z.array(ZTag),
meta: ZResponseMeta.nullable(),
singleUseId: z.string().nullable(),
});
export type TResponse = z.infer<typeof ZResponse>;
@@ -60,6 +61,7 @@ export type TResponse = z.infer<typeof ZResponse>;
export const ZResponseInput = z.object({
surveyId: z.string().cuid2(),
personId: z.string().cuid2().nullable(),
singleUseId: z.string().nullable().optional(),
finished: z.boolean(),
data: ZResponseData,
meta: z

View File

@@ -17,6 +17,17 @@ export const ZSurveyClosedMessage = z
.nullable()
.optional();
export const ZSurveySingleUse = z
.object({
enabled: z.boolean(),
heading: z.optional(z.string()),
subheading: z.optional(z.string()),
isEncrypted: z.boolean(),
})
.nullable();
export type TSurveySingleUse = z.infer<typeof ZSurveySingleUse>;
export const ZSurveyVerifyEmail = z
.object({
name: z.optional(z.string()),
@@ -259,6 +270,7 @@ export const ZSurvey = z.object({
autoComplete: z.number().nullable(),
closeOnDate: z.date().nullable(),
surveyClosedMessage: ZSurveyClosedMessage.nullable(),
singleUse: ZSurveySingleUse.nullable(),
verifyEmail: ZSurveyVerifyEmail.nullable(),
});

View File

@@ -70,6 +70,7 @@
"NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID",
"NEXT_PUBLIC_FORMBRICKS_PMF_FORM_ID",
"NEXT_PUBLIC_FORMBRICKS_URL",
"FORMBRICKS_ENCRYPTION_KEY",
"IMPRINT_URL",
"NEXT_PUBLIC_SENTRY_DSN",
"SURVEY_BASE_URL",