Merge branch 'formbricks:main' into fix/keyboard-form-usablity

This commit is contained in:
Joyal Raphel
2023-10-04 09:30:33 +05:30
committed by GitHub
89 changed files with 1233 additions and 570 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

@@ -299,8 +299,8 @@ const Leaderboard = [
points: "200",
},
{
name: "Arjun",
points: "100",
name: "Naitik Kapadia (Arjun)",
points: "200",
},
{
name: "Yashhhh",
@@ -332,7 +332,7 @@ const Leaderboard = [
},
{
name: "Eldemarkki",
points: "100",
points: "500",
},
{
name: "Suyash",
@@ -360,17 +360,57 @@ const Leaderboard = [
},
{
name: "Aditya Deshlahre",
points: "450",
points: "550",
link: "https://github.com/adityadeshlahre",
},
{
name: "Rutam",
points: "250",
points: "350",
},
{
name: "Sagnik Sahoo",
points: "100",
},
{
name: "Prasoon Mahawar",
points: "100",
},
{
name: "Dushmanta",
points: "100",
},
{
name: "Arjavv",
points: "100",
},
{
name: "Ashish Khare",
points: "100",
},
{
name: "Rohit Mondal",
points: "100",
},
{
name: "noobcoder",
points: "100",
},
{
name: "Rayyan Alam (Rayy)",
points: "100",
},
{
name: "Ayush",
points: "100",
},
{
name: "Zechariah",
points: "100",
},
{
name: "Rajarshi Misra",
points: "100",
},
];
export default function FormTribeHackathon() {

View File

@@ -12,7 +12,7 @@ import {
getActionCountInLast7Days,
getActionCountInLastHour,
} from "@formbricks/lib/services/actions";
import { getSurveysByActionClassId } from "@formbricks/lib/services/survey";
import { getSurveysByActionClassId } from "@formbricks/lib/survey/service";
import { AuthorizationError } from "@formbricks/types/v1/errors";
export async function deleteActionClassAction(environmentId, actionClassId: string) {

View File

@@ -1,6 +1,6 @@
"use server";
import { getSurveysByAttributeClassId } from "@formbricks/lib/services/survey";
import { getSurveysByAttributeClassId } from "@formbricks/lib/survey/service";
export const GetActiveInactiveSurveysAction = async (
attributeClassId: string

View File

@@ -1,133 +1,45 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { prisma } from "@formbricks/database";
import { ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { deleteSurvey, getSurvey } from "@formbricks/lib/services/survey";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createMembership } from "@formbricks/lib/services/membership";
import { createProduct } from "@formbricks/lib/services/product";
import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/services/team";
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
import { deleteSurvey, getSurvey } from "@formbricks/lib/survey/service";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { Team } from "@prisma/client";
import { Prisma as prismaClient } from "@prisma/client/";
import { createProduct } from "@formbricks/lib/services/product";
import { getServerSession } from "next-auth";
export async function createTeam(teamName: string, ownerUserId: string): Promise<Team> {
const newTeam = await prisma.team.create({
data: {
name: teamName,
memberships: {
create: {
user: { connect: { id: ownerUserId } },
role: "owner",
accepted: true,
},
},
products: {
create: [
{
name: "My Product",
environments: {
create: [
{
type: "production",
eventClasses: {
create: [
{
name: "New Session",
description: "Gets fired when a new session is created",
type: "automatic",
},
{
name: "Exit Intent (Desktop)",
description: "A user on Desktop leaves the website with the cursor.",
type: "automatic",
},
{
name: "50% Scroll",
description: "A user scrolled 50% of the current page",
type: "automatic",
},
],
},
attributeClasses: {
create: [
{
name: "userId",
description: "The internal ID of the person",
type: "automatic",
},
{
name: "email",
description: "The email of the person",
type: "automatic",
},
],
},
},
{
type: "development",
eventClasses: {
create: [
{
name: "New Session",
description: "Gets fired when a new session is created",
type: "automatic",
},
{
name: "Exit Intent (Desktop)",
description: "A user on Desktop leaves the website with the cursor.",
type: "automatic",
},
{
name: "50% Scroll",
description: "A user scrolled 50% of the current page",
type: "automatic",
},
],
},
attributeClasses: {
create: [
{
name: "userId",
description: "The internal ID of the person",
type: "automatic",
},
{
name: "email",
description: "The email of the person",
type: "automatic",
},
],
},
},
],
},
},
],
},
},
include: {
memberships: true,
products: {
include: {
environments: true,
},
},
},
export async function createTeamAction(teamName: string): Promise<Team> {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const newTeam = await createTeam({
name: teamName,
});
const teamId = newTeam?.id;
await createMembership(newTeam.id, session.user.id, {
role: "owner",
accepted: true,
});
if (teamId) {
fetch(`${WEBAPP_URL}/api/v1/teams/${teamId}/add_demo_product`, {
method: "POST",
headers: {
"x-api-key": INTERNAL_SECRET,
},
});
}
await createProduct(newTeam.id, {
name: "My Product",
});
return newTeam;
}
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const existingSurvey = await getSurvey(surveyId);
if (!existingSurvey) {
@@ -164,7 +76,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,
@@ -178,6 +92,24 @@ export async function copyToOtherEnvironmentAction(
surveyId: string,
targetEnvironmentId: string
) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess(
session.user.id,
environmentId
);
if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized");
const isAuthorizedToAccessTargetEnvironment = await hasUserEnvironmentAccess(
session.user.id,
targetEnvironmentId
);
if (!isAuthorizedToAccessTargetEnvironment) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const existingSurvey = await prisma.survey.findFirst({
where: {
id: surveyId,
@@ -295,6 +227,7 @@ export async function copyToOtherEnvironmentAction(
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
},
});
@@ -302,12 +235,32 @@ export async function copyToOtherEnvironmentAction(
}
export const deleteSurveyAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
await deleteSurvey(surveyId);
};
export const createProductAction = async (environmentId: string, productName: string) => {
const productCreated = await createProduct(environmentId, productName);
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const newEnvironment = productCreated.environments[0];
return newEnvironment;
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const team = await getTeamByEnvironmentId(environmentId);
if (!team) throw new ResourceNotFoundError("Team from environment", environmentId);
const product = await createProduct(team.id, {
name: productName,
});
// get production environment
const productionEnvironment = product.environments.find((environment) => environment.type === "production");
if (!productionEnvironment) throw new ResourceNotFoundError("Production environment", environmentId);
return productionEnvironment;
};

View File

@@ -2,7 +2,7 @@ import GoogleSheetWrapper from "@/app/(app)/environments/[environmentId]/integra
import GoBackButton from "@/components/shared/GoBackButton";
import { getSpreadSheets } from "@formbricks/lib/services/googleSheet";
import { getIntegrations } from "@formbricks/lib/services/integrations";
import { getSurveys } from "@formbricks/lib/services/survey";
import { getSurveys } from "@formbricks/lib/survey/service";
import { TGoogleSheetIntegration, TGoogleSpreadsheet } from "@formbricks/types/v1/integrations";
import {
GOOGLE_SHEETS_CLIENT_ID,

View File

@@ -4,7 +4,7 @@ import WebhookRowData from "@/app/(app)/environments/[environmentId]/integration
import WebhookTable from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTable";
import WebhookTableHeading from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTableHeading";
import GoBackButton from "@/components/shared/GoBackButton";
import { getSurveys } from "@formbricks/lib/services/survey";
import { getSurveys } from "@formbricks/lib/survey/service";
import { getWebhooks } from "@formbricks/lib/services/webhook";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/services/environment";

View File

@@ -1,6 +1,6 @@
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseTimeline";
import { getSurveys } from "@formbricks/lib/survey/service";
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
import { getSurveys } from "@formbricks/lib/services/survey";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";

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

@@ -1,6 +1,6 @@
"use client";
import { updateTeamAction } from "@/app/(app)/environments/[environmentId]/settings/members/actions";
import { updateTeamNameAction } from "@/app/(app)/environments/[environmentId]/settings/members/actions";
import { TTeam } from "@formbricks/types/v1/teams";
import { Button, Input, Label } from "@formbricks/ui";
import { useRouter } from "next/navigation";
@@ -43,7 +43,7 @@ export default function EditTeamName({ team }: TEditTeamNameProps) {
const handleUpdateTeamName: SubmitHandler<TEditTeamNameForm> = async (data) => {
try {
setIsUpdatingTeam(true);
await updateTeamAction(team.id, data);
await updateTeamNameAction(team.id, data.name);
setIsUpdatingTeam(false);
toast.success("Team name updated successfully.");

View File

@@ -20,13 +20,22 @@ import {
import { deleteTeam, updateTeam } from "@formbricks/lib/services/team";
import { TInviteUpdateInput } from "@formbricks/types/v1/invites";
import { TMembershipRole, TMembershipUpdateInput } from "@formbricks/types/v1/memberships";
import { TTeamUpdateInput } from "@formbricks/types/v1/teams";
import { getServerSession } from "next-auth";
import { hasTeamAccess, hasTeamAuthority, hasTeamOwnership, isOwner } from "@formbricks/lib/auth";
import { INVITE_DISABLED } from "@formbricks/lib/constants";
export const updateTeamAction = async (teamId: string, data: TTeamUpdateInput) => {
return await updateTeam(teamId, data);
export const updateTeamNameAction = async (teamId: string, teamName: string) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
const isUserAuthorized = await hasTeamAuthority(session.user.id, teamId);
if (!isUserAuthorized) {
throw new AuthenticationError("Not authorized");
}
return await updateTeam(teamId, { name: teamName });
};
export const updateMembershipAction = async (

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

@@ -5,11 +5,12 @@ import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { SURVEY_BASE_URL } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getSurveys } from "@formbricks/lib/services/survey";
import { getSurveys } from "@formbricks/lib/survey/service";
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

@@ -4,6 +4,7 @@ import TemplateList from "@/app/(app)/environments/[environmentId]/surveys/templ
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TProduct } from "@formbricks/types/v1/product";
import { TSurveyInput } from "@formbricks/types/v1/surveys";
import { TTemplate } from "@formbricks/types/v1/templates";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -28,7 +29,7 @@ export default function SurveyStarter({
...template.preset,
type: surveyType,
autoComplete,
};
} as Partial<TSurveyInput>;
try {
const survey = await createSurveyAction(environmentId, augmentedTemplate);
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);

View File

@@ -1,7 +1,7 @@
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getSurveyWithAnalytics } from "@formbricks/lib/survey/service";
import { getSurveyResponses } from "@formbricks/lib/response/service";
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
export const getAnalysisData = async (surveyId: string, environmentId: string) => {

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

@@ -6,19 +6,23 @@ import { Button } from "@formbricks/ui";
import { ShareIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import clsx from "clsx";
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
interface LinkSurveyShareButtonProps {
survey: TSurvey;
className?: string;
surveyBaseUrl: string;
singleUseIds?: string[];
}
export default function LinkSurveyShareButton({
survey,
className,
surveyBaseUrl,
singleUseIds,
}: LinkSurveyShareButtonProps) {
const [showLinkModal, setShowLinkModal] = useState(false);
const isSingleUse = survey.singleUse?.enabled ?? false;
return (
<>
@@ -31,7 +35,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}
/>
) : (
<LinkSurveyModal
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

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

@@ -23,7 +23,7 @@ import LinkSurveyShareButton from "@/app/(app)/environments/[environmentId]/surv
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TProduct } from "@formbricks/types/v1/product";
import { surveyMutateAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
import { updateSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
interface SummaryHeaderProps {
surveyId: string;
@@ -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;
@@ -46,7 +54,9 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
<span className="text-base font-extralight text-slate-600">{product.name}</span>
</div>
<div className="hidden justify-end gap-x-1.5 sm:flex">
{survey.type === "link" && <LinkSurveyShareButton survey={survey} surveyBaseUrl={surveyBaseUrl} />}
{survey.type === "link" && (
<LinkSurveyShareButton survey={survey} surveyBaseUrl={surveyBaseUrl} singleUseIds={singleUseIds} />
)}
{(environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? (
<SurveyStatusDropdown environment={environment} survey={survey} />
) : null}
@@ -72,6 +82,7 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
className="flex w-full justify-center p-1"
survey={survey}
surveyBaseUrl={surveyBaseUrl}
singleUseIds={singleUseIds}
/>
<DropdownMenuSeparator />
</>
@@ -97,7 +108,7 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
value={survey.status}
onValueChange={(value) => {
const castedValue = value as "draft" | "inProgress" | "paused" | "completed";
surveyMutateAction({ ...survey, status: castedValue })
updateSurveyAction({ ...survey, status: castedValue })
.then(() => {
toast.success(
value === "inProgress"
@@ -149,7 +160,12 @@ const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }
</DropdownMenuContent>
</DropdownMenu>
</div>
<SuccessMessage environment={environment} survey={survey} surveyBaseUrl={surveyBaseUrl} />
<SuccessMessage
environment={environment}
survey={survey}
surveyBaseUrl={surveyBaseUrl}
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

@@ -14,7 +14,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { validateQuestion } from "./Validation";
import { deleteSurveyAction, surveyMutateAction } from "./actions";
import { deleteSurveyAction, updateSurveyAction } from "./actions";
interface SurveyMenuBarProps {
localSurvey: TSurveyWithAnalytics;
@@ -143,7 +143,7 @@ export default function SurveyMenuBar({
}
try {
await surveyMutateAction({ ...strippedSurvey });
await updateSurveyAction({ ...strippedSurvey });
router.refresh();
setIsMutatingSurvey(false);
toast.success("Changes saved.");
@@ -155,6 +155,7 @@ export default function SurveyMenuBar({
} else {
router.push(`/environments/${environment.id}/surveys`);
}
router.refresh();
}
} catch (e) {
console.error(e);
@@ -241,7 +242,7 @@ export default function SurveyMenuBar({
if (!validateSurvey(localSurvey)) {
return;
}
await surveyMutateAction({ ...localSurvey, status: "inProgress" });
await updateSurveyAction({ ...localSurvey, status: "inProgress" });
router.refresh();
setIsMutatingSurvey(false);
router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary?success=true`);

View File

@@ -1,12 +1,28 @@
"use server";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { deleteSurvey, updateSurvey } from "@formbricks/lib/services/survey";
import { deleteSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";
export async function updateSurveyAction(survey: TSurvey): Promise<TSurvey> {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, survey.id);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
export async function surveyMutateAction(survey: TSurvey): Promise<TSurvey> {
return await updateSurvey(survey);
}
export async function deleteSurveyAction(surveyId: string) {
export const deleteSurveyAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
await deleteSurvey(surveyId);
}
};

View File

@@ -1,8 +1,8 @@
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 { getSurveyWithAnalytics } from "@formbricks/lib/survey/service";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
@@ -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

@@ -1,7 +1,18 @@
"use server";
import { createSurvey } from "@formbricks/lib/services/survey";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createSurvey } from "@formbricks/lib/survey/service";
import { AuthorizationError } from "@formbricks/types/v1/errors";
import { TSurvey } from "@formbricks/types/v1/surveys";
export async function createSurveyAction(environmentId: string, surveyBody: Partial<TSurvey>) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
export async function createSurveyAction(environmentId: string, surveyBody: any) {
return await createSurvey(environmentId, surveyBody);
}

View File

@@ -22,6 +22,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { createSurveyAction } from "./actions";
import { customSurvey, templates } from "./templates";
import { TSurveyInput } from "@formbricks/types/v1/surveys";
type TemplateList = {
environmentId: string;
@@ -77,7 +78,7 @@ export default function TemplateList({
...activeTemplate.preset,
type: surveyType,
autoComplete,
};
} as Partial<TSurveyInput>;
const survey = await createSurveyAction(environmentId, augmentedTemplate);
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
};

View File

@@ -1,7 +1,18 @@
"use server";
import { createSurvey } from "@formbricks/lib/services/survey";
import { createSurvey } from "@formbricks/lib/survey/service";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";
import { TSurvey } from "@formbricks/types/v1/surveys";
export async function createSurveyAction(environmentId: string, surveyBody: Partial<TSurvey>) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
export async function createSurveyAction(environmentId: string, surveyBody: any) {
return await createSurvey(environmentId, surveyBody);
}

View File

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

View File

@@ -41,7 +41,11 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId,
if (selectedObjective) {
try {
setIsProfileUpdating(true);
const updatedProfile = { ...profile, objective: selectedObjective.id };
const updatedProfile = {
...profile,
objective: selectedObjective.id,
name: profile.name ?? undefined,
};
await updateProfileAction(updatedProfile);
setIsProfileUpdating(false);
} catch (e) {

View File

@@ -1,23 +1,25 @@
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getFirstEnvironmentByUserId } from "@formbricks/lib/services/environment";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getProfile } from "@formbricks/lib/services/profile";
import { getServerSession } from "next-auth";
import Onboarding from "./components/Onboarding";
import { getEnvironmentByUser } from "@formbricks/lib/services/environment";
import { getProfile } from "@formbricks/lib/services/profile";
import { ErrorComponent } from "@formbricks/ui";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
export default async function OnboardingPage() {
const session = await getServerSession(authOptions);
const environment = await getEnvironmentByUser(session?.user);
if (!session) {
throw new Error("No session found");
}
const environment = await getFirstEnvironmentByUserId(session?.user.id);
const profile = await getProfile(session?.user.id!);
const product = await getProductByEnvironmentId(environment?.id!);
if (!environment || !profile || !product) {
return <ErrorComponent />;
throw new Error("Failed to get environment, profile, or product");
}
return <Onboarding session={session} environmentId={environment?.id} profile={profile} product={product} />;
return <Onboarding session={session} environmentId={environment.id} profile={profile} product={product} />;
}

View File

@@ -1,5 +1,5 @@
import { writeData } from "@formbricks/lib/services/googleSheet";
import { getSurvey } from "@formbricks/lib/services/survey";
import { getSurvey } from "@formbricks/lib/survey/service";
import { TGoogleSheetIntegration, TIntegration } from "@formbricks/types/v1/integrations";
import { TPipelineInput } from "@formbricks/types/v1/pipelines";

View File

@@ -3,7 +3,7 @@ import { transformErrorToDetails } from "@/lib/api/validator";
import { InvalidInputError } from "@formbricks/types/v1/errors";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { createDisplay } from "@formbricks/lib/services/displays";
import { getSurvey } from "@formbricks/lib/services/survey";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTeamDetails } from "@formbricks/lib/services/teamDetails";
import { TDisplay, ZDisplayInput } from "@formbricks/types/v1/displays";
import { NextResponse } from "next/server";

View File

@@ -2,8 +2,8 @@ import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { sendToPipeline } from "@/lib/pipelines";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { getSurvey } from "@formbricks/lib/survey/service";
import { updateResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/services/survey";
import { ZResponseUpdateInput } from "@formbricks/types/v1/responses";
import { NextResponse } from "next/server";

View File

@@ -3,8 +3,8 @@ import { transformErrorToDetails } from "@/lib/api/validator";
import { sendToPipeline } from "@/lib/pipelines";
import { InvalidInputError } from "@formbricks/types/v1/errors";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { getSurvey } from "@formbricks/lib/survey/service";
import { createResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/services/survey";
import { getTeamDetails } from "@formbricks/lib/services/teamDetails";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/v1/responses";
import { NextResponse } from "next/server";

View File

@@ -1,5 +1,5 @@
import { prisma } from "@formbricks/database";
import { selectSurvey } from "@formbricks/lib/services/survey";
import { selectSurvey } from "@formbricks/lib/survey/service";
import { TPerson } from "@formbricks/types/v1/people";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { unstable_cache } from "next/cache";
@@ -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

@@ -4,7 +4,7 @@ import { transformErrorToDetails } from "@/lib/api/validator";
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service";
import { TResponse, ZResponseUpdateInput } from "@formbricks/types/v1/responses";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getSurvey } from "@formbricks/lib/services/survey";
import { getSurvey } from "@formbricks/lib/survey/service";
import { authenticateRequest } from "@/app/api/v1/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";

View File

@@ -1,6 +1,6 @@
import { responses } from "@/lib/api/response";
import { NextResponse } from "next/server";
import { getSurvey, updateSurvey, deleteSurvey } from "@formbricks/lib/services/survey";
import { getSurvey, updateSurvey, deleteSurvey } from "@formbricks/lib/survey/service";
import { TSurvey, ZSurveyInput } from "@formbricks/types/v1/surveys";
import { transformErrorToDetails } from "@/lib/api/validator";
import { authenticateRequest } from "@/app/api/v1/auth";

View File

@@ -2,7 +2,7 @@ import { responses } from "@/lib/api/response";
import { authenticateRequest } from "@/app/api/v1/auth";
import { NextResponse } from "next/server";
import { transformErrorToDetails } from "@/lib/api/validator";
import { createSurvey, getSurveys } from "@formbricks/lib/services/survey";
import { createSurvey, getSurveys } from "@formbricks/lib/survey/service";
import { ZSurveyInput } from "@formbricks/types/v1/surveys";
import { DatabaseError } from "@formbricks/types/v1/errors";

View File

@@ -1,16 +1,13 @@
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/lib/email";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { populateEnvironment } from "@/lib/populate";
import { prisma } from "@formbricks/database";
import { EMAIL_VERIFICATION_DISABLED, INVITE_DISABLED, SIGNUP_ENABLED } from "@formbricks/lib/constants";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { deleteInvite } from "@formbricks/lib/services/invite";
import { createMembership } from "@formbricks/lib/services/membership";
import { createProduct } from "@formbricks/lib/services/product";
import { createProfile } from "@formbricks/lib/services/profile";
import { createTeam } from "@formbricks/lib/services/team";
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import {
EMAIL_VERIFICATION_DISABLED,
INTERNAL_SECRET,
INVITE_DISABLED,
SIGNUP_ENABLED,
WEBAPP_URL,
} from "@formbricks/lib/constants";
export async function POST(request: Request) {
let { inviteToken, ...user } = await request.json();
@@ -22,7 +19,6 @@ export async function POST(request: Request) {
let inviteId;
try {
let data: Prisma.UserCreateArgs;
let invite;
if (inviteToken) {
@@ -40,92 +36,35 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid invite ID" }, { status: 400 });
}
data = {
data: {
...user,
memberships: {
create: {
accepted: true,
role: invite.role,
team: {
connect: {
id: invite.teamId,
},
},
},
},
},
};
} else {
data = {
data: {
...user,
memberships: {
create: [
{
accepted: true,
role: "owner",
team: {
create: {
name: `${user.name}'s Team`,
products: {
create: [
{
name: "My Product",
environments: {
create: [
{
type: "production",
...populateEnvironment,
},
{
type: "development",
...populateEnvironment,
},
],
},
},
],
},
},
},
},
],
},
},
};
}
// create a user and assign him to the team
type UserWithMemberships = Prisma.UserGetPayload<{ include: { memberships: true } }>;
const userData = (await prisma.user.create({
...data,
include: {
memberships: true,
},
// TODO: This is a hack to get the correct types (casting), we should find a better way to do this
})) as UserWithMemberships;
const teamId = userData.memberships[0].teamId;
if (teamId) {
fetch(`${WEBAPP_URL}/api/v1/teams/${teamId}/add_demo_product`, {
method: "POST",
headers: {
"x-api-key": INTERNAL_SECRET,
},
const profile = await createProfile(user);
await createMembership(invite.teamId, profile.id, {
accepted: true,
role: invite.role,
});
}
if (inviteId) {
sendInviteAcceptedEmail(invite.creator.name, user.name, invite.creator.email);
await prisma.invite.delete({ where: { id: inviteId } });
}
if (!EMAIL_VERIFICATION_DISABLED) {
await sendVerificationEmail(profile);
}
if (!EMAIL_VERIFICATION_DISABLED) {
await sendVerificationEmail(userData);
await sendInviteAcceptedEmail(invite.creator.name, user.name, invite.creator.email);
await deleteInvite(inviteId);
return NextResponse.json(profile);
} else {
const team = await createTeam({
name: `${user.name}'s Team`,
});
await createProduct(team.id, { name: "My Product" });
const profile = await createProfile(user);
await createMembership(team.id, profile.id, { role: "owner", accepted: true });
if (!EMAIL_VERIFICATION_DISABLED) {
await sendVerificationEmail(profile);
}
return NextResponse.json(profile);
}
return NextResponse.json(userData);
} catch (e) {
if (e.code === "P2002") {
return NextResponse.json(

View File

@@ -1,6 +1,6 @@
import ClientLogout from "@/app/ClientLogout";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getEnvironmentByUser } from "@formbricks/lib/services/environment";
import { getFirstEnvironmentByUserId } from "@formbricks/lib/services/environment";
import type { Session } from "next-auth";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
@@ -18,7 +18,10 @@ export default async function Home() {
let environment;
try {
environment = await getEnvironmentByUser(session?.user);
environment = await getFirstEnvironmentByUserId(session?.user.id);
if (!environment) {
throw new Error("No environment found");
}
} catch (error) {
console.error("error getting environment", error);
}

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

@@ -5,13 +5,30 @@ import SurveyInactive from "@/app/s/[surveyId]/SurveyInactive";
import { REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants";
import { getOrCreatePersonByUserId } from "@formbricks/lib/services/person";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getSurvey } from "@formbricks/lib/services/survey";
import { getSurvey } from "@formbricks/lib/survey/service";
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

@@ -2,7 +2,7 @@ import { cn } from "@formbricks/lib/cn";
import SurveyNavBarName from "@/components/shared/SurveyNavBarName";
import Link from "next/link";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getSurvey } from "@formbricks/lib/services/survey";
import { getSurvey } from "@formbricks/lib/survey/service";
interface SecondNavbarProps {
tabs: { id: string; label: string; href: string; icon?: React.ReactNode }[];

View File

@@ -1,6 +1,6 @@
"use client";
import { surveyMutateAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
import { updateSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TSurvey } from "@formbricks/types/v1/surveys";
@@ -44,7 +44,7 @@ export default function SurveyStatusDropdown({
disabled={isStatusChangeDisabled}
onValueChange={(value) => {
const castedValue = value as "draft" | "inProgress" | "paused" | "completed";
surveyMutateAction({ ...survey, status: castedValue })
updateSurveyAction({ ...survey, status: castedValue })
.then(() => {
toast.success(
value === "inProgress"

View File

@@ -1,6 +1,5 @@
import { createTeam } from "@/app/(app)/environments/[environmentId]/actions";
import { createTeamAction } from "@/app/(app)/environments/[environmentId]/actions";
import Modal from "@/components/shared/Modal";
import { useProfile } from "@/lib/profile";
import { Button, Input, Label } from "@formbricks/ui";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
@@ -15,13 +14,12 @@ interface CreateTeamModalProps {
export default function CreateTeamModal({ open, setOpen }: CreateTeamModalProps) {
const router = useRouter();
const { profile } = useProfile();
const [loading, setLoading] = useState(false);
const { register, handleSubmit } = useForm();
const submitTeam = async (data) => {
setLoading(true);
const newTeam = await createTeam(data.name, (profile as any).id);
const newTeam = await createTeamAction(data.name);
toast.success("Team created successfully!");
router.push(`/teams/${newTeam.id}`);

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

@@ -1,28 +0,0 @@
import { EventType } from "@prisma/client";
export const populateEnvironment = {
eventClasses: {
create: [
{
name: "New Session",
description: "Gets fired when a new session is created",
type: EventType.automatic,
},
{
name: "Exit Intent (Desktop)",
description: "A user on Desktop leaves the website with the cursor.",
type: EventType.automatic,
},
{
name: "50% Scroll",
description: "A user scrolled 50% of the current page",
type: EventType.automatic,
},
],
},
attributeClasses: {
create: [
{ name: "userId", description: "The internal ID of the person", type: EventType.automatic },
{ name: "email", description: "The email of the person", type: EventType.automatic },
],
},
};

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

@@ -1,7 +1,6 @@
import { getSessionUser, hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import { EnvironmentType } from "@prisma/client";
import { populateEnvironment } from "@/lib/populate";
import { createProduct } from "@formbricks/lib/services/product";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
@@ -93,29 +92,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
}
// Create a new product and associate it with the current team
const newProduct = await prisma.product.create({
data: {
name,
team: {
connect: { id: environment.product.teamId },
},
environments: {
create: [
{
type: EnvironmentType.production,
...populateEnvironment,
},
{
type: EnvironmentType.development,
...populateEnvironment,
},
],
},
},
select: {
environments: true,
},
});
const newProduct = await createProduct(environment.product.teamId, { name });
const firstEnvironment = newProduct.environments[0];
res.json(firstEnvironment);

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

@@ -1,12 +1,12 @@
import "server-only";
import { ZId } from "@formbricks/types/v1/environment";
import { validateInputs } from "../utils/validate";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { getResponse, getResponseCacheTag } from "./service";
import { unstable_cache } from "next/cache";
import { getSurvey } from "../services/survey";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { getSurvey } from "../survey/service";
import { validateInputs } from "../utils/validate";
import { getResponse, getResponseCacheTag } from "./service";
export const canUserAccessResponse = async (userId: string, responseId: string): Promise<boolean> =>
await unstable_cache(

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

@@ -1,15 +1,24 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { z } from "zod";
import { Prisma, EnvironmentType } from "@prisma/client";
import type {
TEnvironment,
TEnvironmentCreateInput,
TEnvironmentUpdateInput,
} from "@formbricks/types/v1/environment";
import {
ZEnvironment,
ZEnvironmentCreateInput,
ZEnvironmentUpdateInput,
ZId,
} from "@formbricks/types/v1/environment";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/v1/errors";
import type { TEnvironment, TEnvironmentId, TEnvironmentUpdateInput } from "@formbricks/types/v1/environment";
import { populateEnvironment } from "../utils/createDemoProductHelpers";
import { ZEnvironment, ZEnvironmentUpdateInput, ZId } from "@formbricks/types/v1/environment";
import { validateInputs } from "../utils/validate";
import { unstable_cache, revalidateTag } from "next/cache";
import { EventType, Prisma } from "@prisma/client";
import { revalidateTag, unstable_cache } from "next/cache";
import "server-only";
import { z } from "zod";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { validateInputs } from "../utils/validate";
export const getEnvironmentCacheTag = (environmentId: string) => `environments-${environmentId}`;
export const getEnvironmentsCacheTag = (productId: string) => `products-${productId}-environments`;
@@ -125,86 +134,84 @@ export const updateEnvironment = async (
}
};
export const getEnvironmentByUser = async (user: any): Promise<TEnvironment | TEnvironmentId | null> => {
const firstMembership = await prisma.membership.findFirst({
where: {
userId: user.id,
},
select: {
teamId: true,
},
});
if (!firstMembership) {
// create a new team and return environment
const membership = await prisma.membership.create({
data: {
accepted: true,
role: "owner",
user: { connect: { id: user.id } },
team: {
create: {
name: `${user.name}'s Team`,
products: {
create: {
name: "My Product",
environments: {
create: [
{
type: EnvironmentType.production,
...populateEnvironment,
},
{
type: EnvironmentType.development,
...populateEnvironment,
},
],
},
},
},
},
},
},
include: {
team: {
include: {
products: {
include: {
environments: true,
export const getFirstEnvironmentByUserId = async (userId: string): Promise<TEnvironment | null> => {
validateInputs([userId, ZId]);
let environmentPrisma;
try {
environmentPrisma = await prisma.environment.findFirst({
where: {
type: "production",
product: {
team: {
memberships: {
some: {
userId,
},
},
},
},
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
const environment = membership.team.products[0].environments[0];
throw error;
}
try {
const environment = ZEnvironment.parse(environmentPrisma);
return environment;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of environment failed");
}
const firstProduct = await prisma.product.findFirst({
where: {
teamId: firstMembership.teamId,
},
select: {
id: true,
},
});
if (firstProduct === null) {
return null;
}
const firstEnvironment = await prisma.environment.findFirst({
where: {
productId: firstProduct.id,
type: "production",
},
select: {
id: true,
},
});
if (firstEnvironment === null) {
return null;
}
return firstEnvironment;
};
export const createEnvironment = async (
productId: string,
environmentInput: Partial<TEnvironmentCreateInput>
): Promise<TEnvironment> => {
validateInputs([productId, ZId], [environmentInput, ZEnvironmentCreateInput]);
return await prisma.environment.create({
data: {
type: environmentInput.type || "development",
product: { connect: { id: productId } },
widgetSetupCompleted: environmentInput.widgetSetupCompleted || false,
eventClasses: {
create: populateEnvironment.eventClasses,
},
attributeClasses: {
create: populateEnvironment.attributeClasses,
},
},
});
};
export const populateEnvironment = {
eventClasses: [
{
name: "New Session",
description: "Gets fired when a new session is created",
type: EventType.automatic,
},
{
name: "Exit Intent (Desktop)",
description: "A user on Desktop leaves the website with the cursor.",
type: EventType.automatic,
},
{
name: "50% Scroll",
description: "A user scrolled 50% of the current page",
type: EventType.automatic,
},
],
attributeClasses: [
{ name: "userId", description: "The internal ID of the person", type: EventType.automatic },
{ name: "email", description: "The email of the person", type: EventType.automatic },
],
};

View File

@@ -62,6 +62,26 @@ export const getMembershipsByUserId = cache(async (userId: string): Promise<TMem
return memberships;
});
export const createMembership = async (
teamId: string,
userId: string,
data: Partial<TMembership>
): Promise<TMembership> => {
try {
const membership = await prisma.membership.create({
data: {
userId,
teamId,
accepted: data.accepted,
role: data.role as TMembership["role"],
},
});
return membership;
} catch (error) {
throw error;
}
};
export const updateMembership = async (
userId: string,
teamId: string,

View File

@@ -9,11 +9,9 @@ import { Prisma } from "@prisma/client";
import { revalidateTag, unstable_cache } from "next/cache";
import { cache } from "react";
import { z } from "zod";
import { validateInputs } from "../utils/validate";
import { EnvironmentType } from "@prisma/client";
import { EventType } from "@prisma/client";
import { getEnvironmentCacheTag, getEnvironmentsCacheTag } from "./environment";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { validateInputs } from "../utils/validate";
import { createEnvironment, getEnvironmentCacheTag, getEnvironmentsCacheTag } from "./environment";
export const getProductsCacheTag = (teamId: string): string => `teams-${teamId}-products`;
const getProductCacheTag = (environmentId: string): string => `environments-${environmentId}-product`;
@@ -35,34 +33,6 @@ const selectProduct = {
environments: true,
};
const populateEnvironment = {
eventClasses: {
create: [
{
name: "New Session",
description: "Gets fired when a new session is created",
type: EventType.automatic,
},
{
name: "Exit Intent (Desktop)",
description: "A user on Desktop leaves the website with the cursor.",
type: EventType.automatic,
},
{
name: "50% Scroll",
description: "A user scrolled 50% of the current page",
type: EventType.automatic,
},
],
},
attributeClasses: {
create: [
{ name: "userId", description: "The internal ID of the person", type: EventType.automatic },
{ name: "email", description: "The email of the person", type: EventType.automatic },
],
},
};
export const getProducts = async (teamId: string): Promise<TProduct[]> =>
unstable_cache(
async () => {
@@ -135,6 +105,7 @@ export const updateProduct = async (
inputProduct: Partial<TProductUpdateInput>
): Promise<TProduct> => {
validateInputs([productId, ZId], [inputProduct, ZProductUpdateInput.partial()]);
const { environments, ...data } = inputProduct;
let updatedProduct;
try {
updatedProduct = await prisma.product.update({
@@ -142,7 +113,10 @@ export const updateProduct = async (
id: productId,
},
data: {
...inputProduct,
...data,
environments: {
connect: environments?.map((environment) => ({ id: environment.id })) ?? [],
},
},
select: selectProduct,
});
@@ -210,43 +184,35 @@ export const deleteProduct = cache(async (productId: string): Promise<TProduct>
return product;
});
export const createProduct = async (environmentId: string, productName: string): Promise<TProduct> => {
const environment = await prisma.environment.findUnique({
where: { id: environmentId },
select: {
product: {
select: {
teamId: true,
},
},
},
});
if (!environment) {
throw new Error("Invalid environment");
export const createProduct = async (
teamId: string,
productInput: Partial<TProductUpdateInput>
): Promise<TProduct> => {
if (!productInput.name) {
throw new ValidationError("Product Name is required");
}
const { environments, ...data } = productInput;
const newProduct = await prisma.product.create({
let product = await prisma.product.create({
data: {
name: productName,
team: {
connect: { id: environment.product.teamId },
},
environments: {
create: [
{
type: EnvironmentType.production,
...populateEnvironment,
},
{
type: EnvironmentType.development,
...populateEnvironment,
},
],
},
...data,
name: productInput.name,
teamId,
},
select: selectProduct,
});
return newProduct;
const devEnvironment = await createEnvironment(product.id, {
type: "development",
});
const prodEnvironment = await createEnvironment(product.id, {
type: "production",
});
product = await updateProduct(product.id, {
environments: [devEnvironment, prodEnvironment],
});
return product;
};

View File

@@ -4,7 +4,12 @@ import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/v1/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { TMembership, TMembershipRole, ZMembershipRole } from "@formbricks/types/v1/memberships";
import { TProfile, TProfileUpdateInput, ZProfileUpdateInput } from "@formbricks/types/v1/profile";
import {
TProfile,
TProfileCreateInput,
TProfileUpdateInput,
ZProfileUpdateInput,
} from "@formbricks/types/v1/profile";
import { MembershipRole, Prisma } from "@prisma/client";
import { unstable_cache, revalidateTag } from "next/cache";
import { validateInputs } from "../utils/validate";
@@ -149,6 +154,19 @@ const deleteUser = async (userId: string): Promise<TProfile> => {
return profile;
};
export const createProfile = async (data: TProfileCreateInput): Promise<TProfile> => {
validateInputs([data, ZProfileUpdateInput]);
const profile = await prisma.user.create({
data: data,
select: responseSelection,
});
revalidateTag(getProfileByEmailCacheTag(profile.email));
revalidateTag(getProfileCacheTag(profile.id));
return profile;
};
// function to delete a user's profile including teams
export const deleteProfile = async (userId: string): Promise<void> => {
validateInputs([userId, ZId]);

View File

@@ -107,7 +107,20 @@ export const getTeamByEnvironmentId = async (environmentId: string): Promise<TTe
}
)();
export const updateTeam = async (teamId: string, data: TTeamUpdateInput): Promise<TTeam> => {
export const createTeam = async (teamInput: TTeamUpdateInput): Promise<TTeam> => {
try {
const team = await prisma.team.create({
data: teamInput,
select,
});
return team;
} catch (error) {
throw error;
}
};
export const updateTeam = async (teamId: string, data: Partial<TTeamUpdateInput>): Promise<TTeam> => {
try {
const updatedTeam = await prisma.team.update({
where: {

View File

@@ -0,0 +1,24 @@
import { ZId } from "@formbricks/types/v1/environment";
import { validateInputs } from "../utils/validate";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { getSurvey, getSurveyCacheTag } from "./service";
import { unstable_cache } from "next/cache";
export const canUserAccessSurvey = async (userId: string, surveyId: string): Promise<boolean> =>
await unstable_cache(
async () => {
validateInputs([surveyId, ZId], [userId, ZId]);
if (!userId) return false;
const survey = await getSurvey(surveyId);
if (!survey) throw new Error("Survey not found");
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, survey.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
},
[`users-${userId}-surveys-${surveyId}`],
{ revalidate: 30 * 60, tags: [getSurveyCacheTag(surveyId)] }
)(); // 30 minutes

View File

@@ -15,22 +15,15 @@ import { revalidateTag, unstable_cache } from "next/cache";
import { z } from "zod";
import { captureTelemetry } from "../telemetry";
import { validateInputs } from "../utils/validate";
import { getDisplaysCacheTag } from "./displays";
import { getDisplaysCacheTag } from "../services/displays";
import { getResponsesCacheTag } from "../response/service";
// surveys cache key and tags
const getSurveysCacheKey = (environmentId: string): string => `environments-${environmentId}-surveys`;
const getSurveysCacheTag = (environmentId: string): string => `environments-${environmentId}-surveys`;
// survey cache key and tags
export const getSurveyCacheKey = (surveyId: string): string => `surveys-${surveyId}`;
export const getSurveyCacheTag = (surveyId: string): string => `surveys-${surveyId}`;
// survey with analytics cache key
const getSurveysWithAnalyticsCacheKey = (environmentId: string): string =>
`environments-${environmentId}-surveysWithAnalytics`;
const getSurveyWithAnalyticsCacheKey = (surveyId: string): string => `surveyWithAnalytics-${surveyId}`;
export const selectSurvey = {
id: true,
createdAt: true,
@@ -50,6 +43,7 @@ export const selectSurvey = {
verifyEmail: true,
redirectUrl: true,
surveyClosedMessage: true,
singleUse: true,
triggers: {
select: {
eventClass: {
@@ -144,7 +138,7 @@ export const getSurveyWithAnalytics = async (surveyId: string): Promise<TSurveyW
throw new ValidationError("Data validation of survey failed");
}
},
[getSurveyWithAnalyticsCacheKey(surveyId)],
[`surveyWithAnalytics-${surveyId}`],
{
tags: [getSurveyCacheTag(surveyId), getDisplaysCacheTag(surveyId), getResponsesCacheTag(surveyId)],
revalidate: 60 * 30,
@@ -203,7 +197,7 @@ export const getSurvey = async (surveyId: string): Promise<TSurvey | null> => {
throw new ValidationError("Data validation of survey failed");
}
},
[getSurveyCacheKey(surveyId)],
[`surveys-${surveyId}`],
{
tags: [getSurveyCacheTag(surveyId)],
revalidate: 60 * 30,
@@ -328,7 +322,7 @@ export const getSurveys = async (environmentId: string): Promise<TSurvey[]> => {
throw new ValidationError("Data validation of survey failed");
}
},
[getSurveysCacheKey(environmentId)],
[`environments-${environmentId}-surveys`],
{
tags: [getSurveysCacheTag(environmentId)],
revalidate: 60 * 30,
@@ -392,7 +386,7 @@ export const getSurveysWithAnalytics = async (environmentId: string): Promise<TS
throw new ValidationError("Data validation of survey failed");
}
},
[getSurveysWithAnalyticsCacheKey(environmentId)],
[`environments-${environmentId}-surveysWithAnalytics`],
{
tags: [getSurveysCacheTag(environmentId)], // TODO: add tags for displays and responses
}

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

@@ -44,4 +44,12 @@ export const ZActionClassInput = z.object({
type: z.enum(["code", "noCode"]),
});
export const ZActionClassAutomaticInput = z.object({
name: z.string(),
description: z.string().optional(),
type: z.enum(["automatic"]),
});
export type TActionClassAutomaticInput = z.infer<typeof ZActionClassAutomaticInput>;
export type TActionClassInput = z.infer<typeof ZActionClassInput>;

View File

@@ -22,12 +22,20 @@ export const ZAttributeClassInput = z.object({
environmentId: z.string(),
});
export const ZAttributeClassAutomaticInput = z.object({
name: z.string(),
description: z.string(),
type: z.enum(["automatic"]),
});
export const ZAttributeClassUpdateInput = z.object({
name: z.string(),
description: z.string().optional(),
archived: z.boolean().optional(),
});
export type TAttributeClassAutomaticInput = z.infer<typeof ZAttributeClassAutomaticInput>;
export type TAttributeClassUpdateInput = z.infer<typeof ZAttributeClassUpdateInput>;
export type TAttributeClassInput = z.infer<typeof ZAttributeClassInput>;

View File

@@ -25,4 +25,11 @@ export const ZEnvironmentUpdateInput = z.object({
export const ZId = z.string().cuid2();
export const ZEnvironmentCreateInput = z.object({
type: z.enum(["development", "production"]).optional(),
widgetSetupCompleted: z.boolean().optional(),
});
export type TEnvironmentCreateInput = z.infer<typeof ZEnvironmentCreateInput>;
export type TEnvironmentUpdateInput = z.infer<typeof ZEnvironmentUpdateInput>;

View File

@@ -11,7 +11,7 @@ export const ZProduct = z.object({
highlightBorderColor: z
.string()
.regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/)
.nullish(),
.nullable(),
recontactDays: z.number().int(),
formbricksSignature: z.boolean(),
placement: z.enum(["bottomLeft", "bottomRight", "topLeft", "topRight", "center"]),
@@ -26,7 +26,6 @@ export const ZProductUpdateInput = ZProduct.omit({
id: true,
createdAt: true,
updatedAt: true,
environments: true,
});
export type TProductUpdateInput = z.infer<typeof ZProductUpdateInput>;

View File

@@ -21,11 +21,21 @@ export const ZProfile = z.object({
export type TProfile = z.infer<typeof ZProfile>;
export const ZProfileUpdateInput = z.object({
name: z.string().nullable(),
email: z.string(),
onboardingCompleted: z.boolean(),
role: ZRole.nullable(),
objective: ZObjective.nullable(),
name: z.string().nullish(),
email: z.string().optional(),
onboardingCompleted: z.boolean().optional(),
role: ZRole.optional(),
objective: ZObjective.optional(),
});
export type TProfileUpdateInput = z.infer<typeof ZProfileUpdateInput>;
export const ZProfileCreateInput = z.object({
name: z.string().optional(),
email: z.string(),
onboardingCompleted: z.boolean().optional(),
role: ZRole.optional(),
objective: ZObjective.optional(),
});
export type TProfileCreateInput = z.infer<typeof ZProfileCreateInput>;

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

@@ -1,5 +1,4 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
export const ZTeam = z.object({
id: z.string().cuid2(),
@@ -10,6 +9,12 @@ export const ZTeam = z.object({
stripeCustomerId: z.string().nullable(),
});
export type TTeamUpdateInput = Prisma.TeamUpdateInput;
export const ZTeamUpdateInput = z.object({
name: z.string(),
plan: z.enum(["free", "pro"]).optional(),
stripeCustomerId: z.string().nullish(),
});
export type TTeamUpdateInput = z.infer<typeof ZTeamUpdateInput>;
export type TTeam = z.infer<typeof ZTeam>;

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",