diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts index db0748260b..de5f27357a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts @@ -2,7 +2,7 @@ import { prisma } from "@formbricks/database"; import { ResourceNotFoundError } from "@formbricks/errors"; -import { captureTelemetry } from "@formbricks/lib/telemetry"; +import { deleteSurvey, getSurvey } from "@formbricks/lib/services/survey"; import { QuestionType } from "@formbricks/types/questions"; import { createId } from "@paralleldrive/cuid2"; import { Team } from "@prisma/client"; @@ -1376,46 +1376,13 @@ export async function addDemoData(teamId: string): Promise { ); } -export async function deleteSurveyAction(surveyId: string) { - const deletedSurvey = await prisma.survey.delete({ - where: { - id: surveyId, - }, - }); - return deletedSurvey; -} - -export async function createSurveyAction(environmentId: string, surveyBody: any) { - const survey = await prisma.survey.create({ - data: { - ...surveyBody, - environment: { - connect: { - id: environmentId, - }, - }, - }, - }); - captureTelemetry("survey created"); - - return survey; -} - export async function duplicateSurveyAction(environmentId: string, surveyId: string) { - const existingSurvey = await prisma.survey.findFirst({ - where: { - id: surveyId, - environmentId, - }, - include: { - triggers: true, - attributeFilters: true, - }, - }); + const existingSurvey = await getSurvey(surveyId); if (!existingSurvey) { throw new ResourceNotFoundError("Survey", surveyId); } + // create new survey with the data of the existing survey const newSurvey = await prisma.survey.create({ data: { @@ -1428,7 +1395,7 @@ export async function duplicateSurveyAction(environmentId: string, surveyId: str thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)), triggers: { create: existingSurvey.triggers.map((trigger) => ({ - eventClassId: trigger.eventClassId, + eventClassId: trigger.id, })), }, attributeFilters: { @@ -1575,3 +1542,7 @@ export async function copyToOtherEnvironmentAction( }); return newSurvey; } + +export const deleteSurveyAction = async (surveyId: string) => { + await deleteSurvey(surveyId); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu.tsx index f2b6085326..d683ece107 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu.tsx @@ -14,21 +14,21 @@ import { DropdownMenuTrigger, } from "@/components/shared/DropdownMenu"; import LoadingSpinner from "@/components/shared/LoadingSpinner"; +import type { TEnvironment } from "@formbricks/types/v1/environment"; import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys"; import { + ArrowUpOnSquareStackIcon, DocumentDuplicateIcon, EllipsisHorizontalIcon, + EyeIcon, LinkIcon, PencilSquareIcon, - EyeIcon, TrashIcon, - ArrowUpOnSquareStackIcon, } from "@heroicons/react/24/solid"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import toast from "react-hot-toast"; -import type { TEnvironment } from "@formbricks/types/v1/environment"; interface SurveyDropDownMenuProps { environmentId: string; @@ -151,7 +151,7 @@ export default function SurveyDropDownMenu({ Preview Survey diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyStarter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyStarter.tsx index 440ada62e1..afaf8a7130 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyStarter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyStarter.tsx @@ -1,6 +1,6 @@ "use client"; import { Template } from "@/../../packages/types/templates"; -import { createSurveyAction } from "@/app/(app)/environments/[environmentId]/actions"; +import { createSurveyAction } from "./actions"; import TemplateList from "@/app/(app)/environments/[environmentId]/surveys/templates/TemplateList"; import LoadingSpinner from "@/components/shared/LoadingSpinner"; import type { TEnvironment } from "@formbricks/types/v1/environment"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data.ts index ed4e688d9b..cd010a43e7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data.ts @@ -1,11 +1,11 @@ import { IS_FORMBRICKS_CLOUD, RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants"; import { getSurveyResponses } from "@formbricks/lib/services/response"; -import { getSurvey } from "@formbricks/lib/services/survey"; +import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey"; import { getTeamByEnvironmentId } from "@formbricks/lib/services/team"; export const getAnalysisData = async (surveyId: string, environmentId: string) => { const [survey, team, allResponses] = await Promise.all([ - getSurvey(surveyId), + getSurveyWithAnalytics(surveyId), getTeamByEnvironmentId(environmentId), getSurveyResponses(surveyId), ]); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts new file mode 100644 index 0000000000..bd3454703c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts @@ -0,0 +1,7 @@ +"use server"; + +import { createSurvey } from "@formbricks/lib/services/survey"; + +export async function createSurveyAction(environmentId: string, surveyBody: any) { + return await createSurvey(environmentId, surveyBody); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateList.tsx index aa3d36972a..43cabeee97 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateList.tsx @@ -1,21 +1,27 @@ "use client"; -import { createSurvey } from "@/lib/surveys/surveys"; +import LoadingSpinner from "@/components/shared/LoadingSpinner"; +import { useProfile } from "@/lib/profile"; import { replacePresetPlaceholders } from "@/lib/templates"; import { cn } from "@formbricks/lib/cn"; import type { Template } from "@formbricks/types/templates"; -import { Button, ErrorComponent } from "@formbricks/ui"; -import { PlusCircleIcon } from "@heroicons/react/24/outline"; -import { SparklesIcon } from "@heroicons/react/24/solid"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { customSurvey, templates } from "./templates"; -import { SplitIcon } from "lucide-react"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui"; import type { TEnvironment } from "@formbricks/types/v1/environment"; import type { TProduct } from "@formbricks/types/v1/product"; -import { useProfile } from "@/lib/profile"; -import LoadingSpinner from "@/components/shared/LoadingSpinner"; +import { + Button, + ErrorComponent, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@formbricks/ui"; +import { PlusCircleIcon } from "@heroicons/react/24/outline"; +import { SparklesIcon } from "@heroicons/react/24/solid"; +import { SplitIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { createSurveyAction } from "./actions"; +import { customSurvey, templates } from "./templates"; type TemplateList = { environmentId: string; @@ -59,7 +65,7 @@ export default function TemplateList({ environmentId, onTemplateClick, product, ...activeTemplate.preset, type: environment?.widgetSetupCompleted ? "web" : "link", }; - const survey = await createSurvey(environmentId, augmentedTemplate); + const survey = await createSurveyAction(environmentId, augmentedTemplate); router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/actions.ts new file mode 100644 index 0000000000..bd3454703c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/actions.ts @@ -0,0 +1,7 @@ +"use server"; + +import { createSurvey } from "@formbricks/lib/services/survey"; + +export async function createSurveyAction(environmentId: string, surveyBody: any) { + return await createSurvey(environmentId, surveyBody); +} diff --git a/apps/web/app/api/v1/js/surveys.ts b/apps/web/app/api/v1/js/surveys.ts index 85d8f3918e..831d6404b1 100644 --- a/apps/web/app/api/v1/js/surveys.ts +++ b/apps/web/app/api/v1/js/surveys.ts @@ -1,5 +1,5 @@ import { prisma } from "@formbricks/database"; -import { select } from "@formbricks/lib/services/survey"; +import { selectSurvey } from "@formbricks/lib/services/survey"; import { TPerson } from "@formbricks/types/v1/people"; import { TSurvey } from "@formbricks/types/v1/surveys"; @@ -48,7 +48,7 @@ export const getSurveys = async (environmentId: string, person: TPerson): Promis ], }, select: { - ...select, + ...selectSurvey, attributeFilters: { select: { id: true, diff --git a/packages/lib/services/survey.ts b/packages/lib/services/survey.ts index 8f26a82d59..48da4ff26c 100644 --- a/packages/lib/services/survey.ts +++ b/packages/lib/services/survey.ts @@ -1,18 +1,13 @@ import { prisma } from "@formbricks/database"; -import { z } from "zod"; -import { ValidationError } from "@formbricks/errors"; -import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors"; -import { - TSurvey, - TSurveyWithAnalytics, - ZSurvey, - ZSurveyWithAnalytics -} from "@formbricks/types/v1/surveys"; +import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/errors"; +import { TSurvey, TSurveyWithAnalytics, ZSurvey, ZSurveyWithAnalytics } from "@formbricks/types/v1/surveys"; import { Prisma } from "@prisma/client"; -import "server-only"; import { cache } from "react"; +import "server-only"; +import { z } from "zod"; +import { captureTelemetry } from "../telemetry"; -export const select = { +export const selectSurveyWithAnalytics = { id: true, createdAt: true, updatedAt: true, @@ -53,31 +48,126 @@ export const select = { value: true, }, }, -displays:{ - select:{ - status:true, - id:true - } -}, -_count:{ - select:{ - responses:true - } -} -} - -export const preloadSurvey = (surveyId: string) => { - void getSurvey(surveyId); + displays: { + select: { + status: true, + id: true, + }, + }, + _count: { + select: { + responses: true, + }, + }, }; -export const getSurvey = cache(async (surveyId: string): Promise => { +export const selectSurvey = { + id: true, + createdAt: true, + updatedAt: true, + name: true, + type: true, + environmentId: true, + status: true, + questions: true, + thankYouCard: true, + displayOption: true, + recontactDays: true, + autoClose: true, + closeOnDate: true, + delay: true, + autoComplete: true, + redirectUrl: true, + triggers: { + select: { + eventClass: { + select: { + id: true, + createdAt: true, + updatedAt: true, + environmentId: true, + name: true, + description: true, + type: true, + noCodeConfig: true, + }, + }, + }, + }, + attributeFilters: { + select: { + id: true, + attributeClassId: true, + condition: true, + value: true, + }, + }, +}; + +export const preloadSurveyWithAnalytics = (surveyId: string) => { + void getSurveyWithAnalytics(surveyId); +}; + +export const getSurveyWithAnalytics = cache( + async (surveyId: string): Promise => { + let surveyPrisma; + try { + surveyPrisma = await prisma.survey.findUnique({ + where: { + id: surveyId, + }, + select: selectSurveyWithAnalytics, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } + + if (!surveyPrisma) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + let { _count, displays, ...surveyPrismaFields } = surveyPrisma; + + const numDisplays = displays.length; + const numDisplaysResponded = displays.filter((item) => item.status === "responded").length; + const numResponses = _count.responses; + // responseRate, rounded to 2 decimal places + const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0; + + const transformedSurvey = { + ...surveyPrismaFields, + triggers: surveyPrismaFields.triggers.map((trigger) => trigger.eventClass), + analytics: { + numDisplays, + responseRate, + numResponses, + }, + }; + + try { + const survey = ZSurveyWithAnalytics.parse(transformedSurvey); + return survey; + } catch (error) { + if (error instanceof z.ZodError) { + console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information + } + throw new ValidationError("Data validation of survey failed"); + } + } +); + +export const getSurvey = cache(async (surveyId: string): Promise => { let surveyPrisma; try { surveyPrisma = await prisma.survey.findUnique({ where: { id: surveyId, }, - select + select: selectSurvey, }); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -88,29 +178,16 @@ export const getSurvey = cache(async (surveyId: string): Promiseitem.status==='responded').length - const numResponses=_count.responses - // responseRate, rounded to 2 decimal places - const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0; - const transformedSurvey = { - ...surveyPrismaFields, - triggers: surveyPrismaFields.triggers.map((trigger) => trigger.eventClass), - analytics: { - numDisplays, - responseRate, - numResponses - }, + ...surveyPrisma, + triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass), }; try { - const survey = ZSurveyWithAnalytics.parse(transformedSurvey); + const survey = ZSurvey.parse(transformedSurvey); return survey; } catch (error) { if (error instanceof z.ZodError) { @@ -127,7 +204,7 @@ export const getSurveys = cache(async (environmentId: string): Promise trigger.eventClass), - }; - const survey = ZSurvey.parse(transformedSurvey); - surveys.push(survey); - } try { + for (const surveyPrisma of surveysPrisma) { + const transformedSurvey = { + ...surveyPrisma, + triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass), + }; + const survey = ZSurvey.parse(transformedSurvey); + surveys.push(survey); + } return surveys; } catch (error) { if (error instanceof z.ZodError) { @@ -157,48 +234,75 @@ export const getSurveys = cache(async (environmentId: string): Promise => { - let surveysPrisma; - try { - surveysPrisma = await prisma.survey.findMany({ - where: { - environmentId, - }, - select - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError("Database operation failed"); - } - - throw error; - } - - const surveys: TSurveyWithAnalytics[] = []; - for (const {_count,displays, ...surveyPrisma} of surveysPrisma) { - const numDisplays=displays.length - const numDisplaysResponded=displays.filter((item)=>item.status==='responded').length - const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0; - - const transformedSurvey = { - ...surveyPrisma, - triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass), - analytics:{ - numDisplays, - responseRate, - numResponses:_count.responses +export const getSurveysWithAnalytics = cache( + async (environmentId: string): Promise => { + let surveysPrisma; + try { + surveysPrisma = await prisma.survey.findMany({ + where: { + environmentId, + }, + select: selectSurveyWithAnalytics, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); } - }; - const survey = ZSurveyWithAnalytics.parse(transformedSurvey); - surveys.push(survey); - } - try { - return surveys; - } catch (error) { - if (error instanceof z.ZodError) { - console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information + throw error; + } + + try { + const surveys: TSurveyWithAnalytics[] = []; + for (const { _count, displays, ...surveyPrisma } of surveysPrisma) { + const numDisplays = displays.length; + const numDisplaysResponded = displays.filter((item) => item.status === "responded").length; + const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0; + + const transformedSurvey = { + ...surveyPrisma, + triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass), + analytics: { + numDisplays, + responseRate, + numResponses: _count.responses, + }, + }; + const survey = ZSurveyWithAnalytics.parse(transformedSurvey); + surveys.push(survey); + } + return surveys; + } catch (error) { + if (error instanceof z.ZodError) { + console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information + } + throw new ValidationError("Data validation of survey failed"); } - throw new ValidationError("Data validation of survey failed"); } -}); +); + +export async function deleteSurvey(surveyId: string) { + const deletedSurvey = await prisma.survey.delete({ + where: { + id: surveyId, + }, + select: selectSurvey, + }); + return deletedSurvey; +} + +export async function createSurvey(environmentId: string, surveyBody: any) { + const survey = await prisma.survey.create({ + data: { + ...surveyBody, + environment: { + connect: { + id: environmentId, + }, + }, + }, + }); + captureTelemetry("survey created"); + + return survey; +} diff --git a/packages/types/v1/surveys.ts b/packages/types/v1/surveys.ts index ea3424251e..5afc9faa2f 100644 --- a/packages/types/v1/surveys.ts +++ b/packages/types/v1/surveys.ts @@ -238,6 +238,7 @@ export const ZSurvey = z.object({ delay: z.number(), autoComplete: z.union([z.number(), z.null()]), closeOnDate: z.date().nullable(), + surveyClosedMessage: ZSurveyClosedMessage, }); export type TSurvey = z.infer; @@ -248,7 +249,6 @@ export const ZSurveyWithAnalytics = ZSurvey.extend({ responseRate: z.number(), numResponses: z.number(), }), - surveyClosedMessage: ZSurveyClosedMessage, }); export type TSurveyWithAnalytics = z.infer;