diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts index 47fac4238a..de5f27357a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts @@ -1,9 +1,12 @@ "use server"; import { prisma } from "@formbricks/database"; -import { Team } from "@prisma/client"; +import { ResourceNotFoundError } from "@formbricks/errors"; +import { deleteSurvey, getSurvey } from "@formbricks/lib/services/survey"; import { QuestionType } from "@formbricks/types/questions"; import { createId } from "@paralleldrive/cuid2"; +import { Team } from "@prisma/client"; +import { Prisma as prismaClient } from "@prisma/client/"; export async function createTeam(teamName: string, ownerUserId: string): Promise { const newTeam = await prisma.team.create({ @@ -1372,3 +1375,174 @@ export async function addDemoData(teamId: string): Promise { InterviewPromptResults.displays ); } + +export async function duplicateSurveyAction(environmentId: string, surveyId: string) { + 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: { + ...existingSurvey, + id: undefined, // id is auto-generated + environmentId: undefined, // environmentId is set below + name: `${existingSurvey.name} (copy)`, + status: "draft", + questions: JSON.parse(JSON.stringify(existingSurvey.questions)), + thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)), + triggers: { + create: existingSurvey.triggers.map((trigger) => ({ + eventClassId: trigger.id, + })), + }, + attributeFilters: { + create: existingSurvey.attributeFilters.map((attributeFilter) => ({ + attributeClassId: attributeFilter.attributeClassId, + condition: attributeFilter.condition, + value: attributeFilter.value, + })), + }, + environment: { + connect: { + id: environmentId, + }, + }, + surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull, + }, + }); + return newSurvey; +} + +export async function copyToOtherEnvironmentAction( + environmentId: string, + surveyId: string, + targetEnvironmentId: string +) { + const existingSurvey = await prisma.survey.findFirst({ + where: { + id: surveyId, + environmentId, + }, + include: { + triggers: { + include: { + eventClass: true, + }, + }, + attributeFilters: { + include: { + attributeClass: true, + }, + }, + }, + }); + + if (!existingSurvey) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + let targetEnvironmentTriggers: string[] = []; + // map the local triggers to the target environment + for (const trigger of existingSurvey.triggers) { + const targetEnvironmentTrigger = await prisma.eventClass.findFirst({ + where: { + name: trigger.eventClass.name, + environment: { + id: targetEnvironmentId, + }, + }, + }); + if (!targetEnvironmentTrigger) { + // if the trigger does not exist in the target environment, create it + const newTrigger = await prisma.eventClass.create({ + data: { + name: trigger.eventClass.name, + environment: { + connect: { + id: targetEnvironmentId, + }, + }, + description: trigger.eventClass.description, + type: trigger.eventClass.type, + noCodeConfig: trigger.eventClass.noCodeConfig + ? JSON.parse(JSON.stringify(trigger.eventClass.noCodeConfig)) + : undefined, + }, + }); + targetEnvironmentTriggers.push(newTrigger.id); + } else { + targetEnvironmentTriggers.push(targetEnvironmentTrigger.id); + } + } + + let targetEnvironmentAttributeFilters: string[] = []; + // map the local attributeFilters to the target env + for (const attributeFilter of existingSurvey.attributeFilters) { + // check if attributeClass exists in target env. + // if not, create it + const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({ + where: { + name: attributeFilter.attributeClass.name, + environment: { + id: targetEnvironmentId, + }, + }, + }); + if (!targetEnvironmentAttributeClass) { + const newAttributeClass = await prisma.attributeClass.create({ + data: { + name: attributeFilter.attributeClass.name, + description: attributeFilter.attributeClass.description, + type: attributeFilter.attributeClass.type, + environment: { + connect: { + id: targetEnvironmentId, + }, + }, + }, + }); + targetEnvironmentAttributeFilters.push(newAttributeClass.id); + } else { + targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id); + } + } + + // create new survey with the data of the existing survey + const newSurvey = await prisma.survey.create({ + data: { + ...existingSurvey, + id: undefined, // id is auto-generated + environmentId: undefined, // environmentId is set below + name: `${existingSurvey.name} (copy)`, + status: "draft", + questions: JSON.parse(JSON.stringify(existingSurvey.questions)), + thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)), + triggers: { + create: targetEnvironmentTriggers.map((eventClassId) => ({ + eventClassId: eventClassId, + })), + }, + attributeFilters: { + create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({ + attributeClassId: targetEnvironmentAttributeFilters[idx], + condition: attributeFilter.condition, + value: attributeFilter.value, + })), + }, + environment: { + connect: { + id: targetEnvironmentId, + }, + }, + surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull, + }, + }); + 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 new file mode 100644 index 0000000000..d683ece107 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { + copyToOtherEnvironmentAction, + deleteSurveyAction, + duplicateSurveyAction, +} from "@/app/(app)/environments/[environmentId]/actions"; +import DeleteDialog from "@/components/shared/DeleteDialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + 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, + TrashIcon, +} 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"; + +interface SurveyDropDownMenuProps { + environmentId: string; + survey: TSurveyWithAnalytics; + environment: TEnvironment; + otherEnvironment: TEnvironment; +} + +export default function SurveyDropDownMenu({ + environmentId, + survey, + environment, + otherEnvironment, +}: SurveyDropDownMenuProps) { + const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const handleDeleteSurvey = async (survey) => { + setLoading(true); + try { + await deleteSurveyAction(survey.id); + router.refresh(); + setDeleteDialogOpen(false); + toast.success("Survey deleted successfully."); + } catch (error) { + toast.error("An error occured while deleting survey"); + } + setLoading(false); + }; + + const duplicateSurveyAndRefresh = async (surveyId) => { + setLoading(true); + try { + await duplicateSurveyAction(environmentId, surveyId); + router.refresh(); + toast.success("Survey duplicated successfully."); + } catch (error) { + toast.error("Failed to duplicate the survey."); + } + setLoading(false); + }; + + const copyToOtherEnvironment = async (surveyId) => { + setLoading(true); + try { + await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id); + if (otherEnvironment.type === "production") { + toast.success("Survey copied to production env."); + } else if (otherEnvironment.type === "development") { + toast.success("Survey copied to development env."); + } + router.replace(`/environments/${otherEnvironment.id}`); + } catch (error) { + toast.error(`Failed to copy to ${otherEnvironment.type}`); + } + setLoading(false); + }; + if (loading) { + return ( +
+ +
+ ); + } + return ( + <> + + +
+ Open options +
+
+ + + + + + Edit + + + + + + {environment.type === "development" ? ( + + + + ) : environment.type === "production" ? ( + + + + ) : null} + {survey.type === "link" && survey.status !== "draft" && ( + <> + + + + Preview Survey + + + + + + + )} + + + + + +
+ + handleDeleteSurvey(survey)} + text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone." + /> + + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx index 00dab39e24..ced9aef487 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx @@ -1,156 +1,30 @@ -"use client"; - -import { Template } from "@/../../packages/types/templates"; -import DeleteDialog from "@/components/shared/DeleteDialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/shared/DropdownMenu"; -import LoadingSpinner from "@/components/shared/LoadingSpinner"; import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator"; -import { useEnvironment } from "@/lib/environments/environments"; -import { createSurvey, deleteSurvey, duplicateSurvey, useSurveys } from "@/lib/surveys/surveys"; -import { Badge, ErrorComponent } from "@formbricks/ui"; -import { - ComputerDesktopIcon, - DocumentDuplicateIcon, - EllipsisHorizontalIcon, - LinkIcon, - PencilSquareIcon, - EyeIcon, - TrashIcon, - PlusIcon, - ArrowUpOnSquareStackIcon, -} from "@heroicons/react/24/solid"; +import { Badge } from "@formbricks/ui"; +import { ComputerDesktopIcon, LinkIcon, PlusIcon } 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 TemplateList from "./templates/TemplateList"; -import { useEffect } from "react"; -import { changeEnvironment } from "@/lib/environments/changeEnvironments"; -import { TProduct } from "@formbricks/types/v1/product"; +import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu"; +import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/SurveyStarter"; +import { getProductByEnvironmentId } from "@formbricks/lib/services/product"; +import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment"; +import { getSurveysWithAnalytics } from "@formbricks/lib/services/survey"; +import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys"; +import type { TEnvironment } from "@formbricks/types/v1/environment"; -interface SurveyListProps { - environmentId: string; - product: TProduct; -} - -export default function SurveysList({ environmentId, product }: SurveyListProps) { - const router = useRouter(); - const { surveys, mutateSurveys, isLoadingSurveys, isErrorSurveys } = useSurveys(environmentId); - const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId); - const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false); - - const [activeSurvey, setActiveSurvey] = useState("" as any); - const [activeSurveyIdx, setActiveSurveyIdx] = useState("" as any); - const [otherEnvironment, setOtherEnvironment] = useState("" as any); - - useEffect(() => { - if (environment) { - setOtherEnvironment(environment.product.environments.find((e) => e.type !== environment.type)); - } - }, [environment]); - - const newSurvey = async () => { - router.push(`/environments/${environmentId}/surveys/templates`); - }; - - const newSurveyFromTemplate = async (template: Template) => { - setIsCreateSurveyLoading(true); - const augmentedTemplate = { - ...template.preset, - type: environment?.widgetSetupCompleted ? "web" : "link", - }; - try { - const survey = await createSurvey(environmentId, augmentedTemplate); - router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`); - } catch (e) { - toast.error("An error occured creating a new survey"); - setIsCreateSurveyLoading(false); - } - }; - - const deleteSurveyAction = async (survey, surveyIdx) => { - try { - await deleteSurvey(environmentId, survey.id); - // remove locally - const updatedsurveys = JSON.parse(JSON.stringify(surveys)); - updatedsurveys.splice(surveyIdx, 1); - mutateSurveys(updatedsurveys); - setDeleteDialogOpen(false); - toast.success("Survey deleted successfully."); - } catch (error) { - console.error(error); - } - }; - - const duplicateSurveyAndRefresh = async (surveyId) => { - try { - await duplicateSurvey(environmentId, surveyId); - mutateSurveys(); - toast.success("Survey duplicated successfully."); - } catch (error) { - toast.error("Failed to duplicate the survey."); - } - }; - - const copyToOtherEnvironment = async (surveyId) => { - try { - await duplicateSurvey(environmentId, surveyId, otherEnvironment.id); - if (otherEnvironment.type === "production") { - toast.success("Survey copied to production env."); - } else if (otherEnvironment.type === "development") { - toast.success("Survey copied to development env."); - } - changeEnvironment(otherEnvironment.type, environment, router); - } catch (error) { - toast.error(`Failed to copy to ${otherEnvironment.type}`); - } - }; - - if (isLoadingSurveys || isLoadingEnvironment) { - return ; - } - - if (isErrorSurveys || isErrorEnvironment) { - return ; - } +export default async function SurveysList({ environmentId }: { environmentId: string }) { + const product = await getProductByEnvironmentId(environmentId); + const environment = await getEnvironment(environmentId); + const surveys: TSurveyWithAnalytics[] = await getSurveysWithAnalytics(environmentId); + const environments: TEnvironment[] = await getEnvironments(product.id); + const otherEnvironment = environments.find((e) => e.type !== environment.type); if (surveys.length === 0) { - return ( -
- {isCreateSurveyLoading ? ( - - ) : ( - <> -
-

- You're all set! Time to create your first survey. -

-
- { - newSurveyFromTemplate(template); - }} - environment={environment} - product={product} - /> - - )} -
- ); + return ; } return ( <>
    - + {surveys - .sort((a, b) => b.updatedAt - a.updatedAt) - .map((survey, surveyIdx) => ( + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) + .map((survey) => (
  • @@ -198,119 +72,28 @@ export default function SurveysList({ environmentId, product }: SurveyListProps) tooltip environmentId={environmentId} /> -

    {survey._count?.responses} responses

    +

    + {survey.analytics.numResponses} responses +

    )} {survey.status === "draft" && ( Draft )}
    - - - -
    - Open options -
    -
    - - - - - - Edit - - - - - - {environment.type === "development" ? ( - - - - ) : environment.type === "production" ? ( - - - - ) : null} - {survey.type === "link" && survey.status !== "draft" && ( - <> - - - - Preview Survey - - - - - - - )} - - - - - -
    +
  • ))}
- - deleteSurveyAction(activeSurvey, activeSurveyIdx)} - text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone." - /> ); } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyStarter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyStarter.tsx new file mode 100644 index 0000000000..afaf8a7130 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyStarter.tsx @@ -0,0 +1,60 @@ +"use client"; +import { Template } from "@/../../packages/types/templates"; +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"; +import type { TProduct } from "@formbricks/types/v1/product"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "react-hot-toast"; + +export default function SurveyStarter({ + environmentId, + environment, + product, +}: { + environmentId: string; + environment: TEnvironment; + product: TProduct; +}) { + const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false); + const router = useRouter(); + const newSurveyFromTemplate = async (template: Template) => { + setIsCreateSurveyLoading(true); + const augmentedTemplate = { + ...template.preset, + type: environment?.widgetSetupCompleted ? "web" : "link", + }; + try { + const survey = await createSurveyAction(environmentId, augmentedTemplate); + router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`); + } catch (e) { + toast.error("An error occured creating a new survey"); + setIsCreateSurveyLoading(false); + } + }; + return ( +
+ {isCreateSurveyLoading ? ( + + ) : ( + <> +
+

+ You're all set! Time to create your first survey. +

+
+ { + newSurveyFromTemplate(template); + }} + environment={environment} + product={product} + /> + + )} +
+ ); +} 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/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/loading.tsx new file mode 100644 index 0000000000..a0d2b243dd --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/loading.tsx @@ -0,0 +1,5 @@ +import LoadingSpinner from "@/components/shared/LoadingSpinner"; + +export default function LoadingPage() { + return ; +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/page.tsx index 52d76c2780..fb72c0149e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/page.tsx @@ -2,19 +2,13 @@ import ContentWrapper from "@/components/shared/ContentWrapper"; import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator"; import SurveysList from "./SurveyList"; import { Metadata } from "next"; -import { getProductByEnvironmentId } from "@formbricks/lib/services/product"; - export const metadata: Metadata = { title: "Your Surveys", }; - export default async function SurveysPage({ params }) { - const environmentId = params.environmentId; - const product = await getProductByEnvironmentId(environmentId); - return ( - + ); 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/apps/web/components/shared/SurveyStatusIndicator.tsx b/apps/web/components/shared/SurveyStatusIndicator.tsx index d29c82261e..01feab111a 100644 --- a/apps/web/components/shared/SurveyStatusIndicator.tsx +++ b/apps/web/components/shared/SurveyStatusIndicator.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui"; import { useEnvironment } from "@/lib/environments/environments"; import { ArchiveBoxIcon, CheckIcon, PauseIcon } from "@heroicons/react/24/solid"; diff --git a/packages/lib/services/environment.ts b/packages/lib/services/environment.ts index 143b05cf14..550e43bb40 100644 --- a/packages/lib/services/environment.ts +++ b/packages/lib/services/environment.ts @@ -37,3 +37,43 @@ export const getEnvironment = cache(async (environmentId: string): Promise => { + let productPrisma; + try { + productPrisma = await prisma.product.findFirst({ + where: { + id: productId, + }, + include:{ + environments:true + } + }); + + if (!productPrisma) { + throw new ResourceNotFoundError("Product", productId); + } + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + throw error; + } + + const environments:TEnvironment[]=[]; + for(let environment of productPrisma.environments){ + let targetEnvironment:TEnvironment=ZEnvironment.parse(environment); + environments.push(targetEnvironment); + } + + try { + return environments; + } catch (error) { + if (error instanceof z.ZodError) { + console.error(JSON.stringify(error.errors, null, 2)); + } + throw new ValidationError("Data validation of environments array failed"); + } + } +); \ No newline at end of file diff --git a/packages/lib/services/product.ts b/packages/lib/services/product.ts index 12451da4e9..dc707f5313 100644 --- a/packages/lib/services/product.ts +++ b/packages/lib/services/product.ts @@ -41,3 +41,4 @@ export const getProductByEnvironmentId = cache(async (environmentId: string): Pr throw new ValidationError("Data validation of product failed"); } }); + diff --git a/packages/lib/services/survey.ts b/packages/lib/services/survey.ts index a7ce3be509..48da4ff26c 100644 --- a/packages/lib/services/survey.ts +++ b/packages/lib/services/survey.ts @@ -1,13 +1,13 @@ import { prisma } from "@formbricks/database"; -import { z } from "zod"; -import { ValidationError } from "@formbricks/errors"; -import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors"; +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, @@ -23,6 +23,61 @@ export const select = { 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, + }, + }, + displays: { + select: { + status: true, + id: true, + }, + }, + _count: { + select: { + responses: true, + }, + }, +}; + +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: { @@ -49,18 +104,70 @@ export const select = { }, }; -export const preloadSurvey = (surveyId: string) => { - void getSurvey(surveyId); +export const preloadSurveyWithAnalytics = (surveyId: string) => { + void getSurveyWithAnalytics(surveyId); }; -export const getSurvey = cache(async (surveyId: string): Promise => { +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) { @@ -71,36 +178,16 @@ export const getSurvey = cache(async (surveyId: string): Promise trigger.eventClass), - analytics: { - numDisplays, - responseRate, - }, }; try { - const survey = ZSurveyWithAnalytics.parse(transformedSurvey); + const survey = ZSurvey.parse(transformedSurvey); return survey; } catch (error) { if (error instanceof z.ZodError) { @@ -117,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) { @@ -146,3 +233,76 @@ export const getSurveys = 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"); + } + + 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"); + } + } +); + +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 dd80b7ea38..5afc9faa2f 100644 --- a/packages/types/v1/surveys.ts +++ b/packages/types/v1/surveys.ts @@ -231,13 +231,14 @@ export const ZSurvey = z.object({ displayOption: z.enum(["displayOnce", "displayMultiple", "respondMultiple"]), autoClose: z.union([z.number(), z.null()]), triggers: z.array(ZActionClass), - redirectUrl: z.string().url().optional(), + redirectUrl: z.string().url().nullable(), recontactDays: z.union([z.number(), z.null()]), questions: ZSurveyQuestions, thankYouCard: ZSurveyThankYouCard, delay: z.number(), autoComplete: z.union([z.number(), z.null()]), closeOnDate: z.date().nullable(), + surveyClosedMessage: ZSurveyClosedMessage, }); export type TSurvey = z.infer; @@ -246,8 +247,8 @@ export const ZSurveyWithAnalytics = ZSurvey.extend({ analytics: z.object({ numDisplays: z.number(), responseRate: z.number(), + numResponses: z.number(), }), - surveyClosedMessage: ZSurveyClosedMessage, }); export type TSurveyWithAnalytics = z.infer;