diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList.tsx index 05b9587ed5..1856d8e6a2 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList.tsx +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList.tsx @@ -4,7 +4,7 @@ import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; -import { createSurveyAction } from "@/modules/surveys/components/TemplateList/actions"; +import { createSurveyAction } from "@/modules/survey/components/template-list/actions"; import { useTranslate } from "@tolgee/react"; import { ActivityIcon, ShoppingCartIcon, SmileIcon, StarIcon, ThumbsUpIcon, UsersIcon } from "lucide-react"; import { useRouter } from "next/navigation"; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.tsx index 151922dac0..3abbc14d63 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.tsx @@ -28,7 +28,7 @@ const OnboardingLayout = async (props) => { throw new Error(t("common.organization_not_found")); } - const organizationProjectsLimit = await getOrganizationProjectsLimit(organization); + const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits); const organizationProjectsCount = await getOrganizationProjectsCount(organization.id); if (organizationProjectsCount >= organizationProjectsLimit) { diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx index a8b93a7fca..9c7d4f856c 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx @@ -48,7 +48,7 @@ const Page = async (props: ProjectSettingsPageProps) => { throw new Error(t("common.organization_not_found")); } - const canDoRoleManagement = await getRoleManagementPermission(organization); + const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); if (!organizationTeams) { throw new Error(t("common.organization_teams_not_found")); diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/loading.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/loading.tsx index a77f0bb43e..1fdcdb9750 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/loading.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/loading.tsx @@ -1,7 +1,3 @@ -import { LoadingSkeleton } from "./components/LoadingSkeleton"; +import { SurveyEditorLoading } from "@/modules/survey/editor/loading"; -const Loading = () => { - return ; -}; - -export default Loading; +export default SurveyEditorLoading; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx index e19384c5c4..358bc38c5e 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx @@ -1,132 +1,3 @@ -import { getUserEmail } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/user"; -import { authOptions } from "@/modules/auth/lib/authOptions"; -import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contacts"; -import { getSegments } from "@/modules/ee/contacts/segments/lib/segments"; -import { getIsContactsEnabled, getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; -import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; -import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; -import { getSurveyFollowUpsPermission } from "@/modules/survey-follow-ups/lib/utils"; -import { ErrorComponent } from "@/modules/ui/components/error-component"; -import { getTranslate } from "@/tolgee/server"; -import { getServerSession } from "next-auth"; -import { getActionClasses } from "@formbricks/lib/actionClass/service"; -import { - DEFAULT_LOCALE, - IS_FORMBRICKS_CLOUD, - MAIL_FROM, - SURVEY_BG_COLORS, - UNSPLASH_ACCESS_KEY, -} from "@formbricks/lib/constants"; -import { getEnvironment } from "@formbricks/lib/environment/service"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; -import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; -import { getSurvey } from "@formbricks/lib/survey/service"; -import { getUserLocale } from "@formbricks/lib/user/service"; -import { SurveyEditor } from "./components/SurveyEditor"; +import { SurveyEditorPage } from "@/modules/survey/editor/page"; -export const generateMetadata = async (props) => { - const params = await props.params; - const survey = await getSurvey(params.surveyId); - return { - title: survey?.name ? `${survey?.name} | Editor` : "Editor", - }; -}; - -const Page = async (props) => { - const searchParams = await props.searchParams; - const params = await props.params; - const t = await getTranslate(); - const [ - survey, - project, - environment, - actionClasses, - contactAttributeKeys, - responseCount, - organization, - session, - segments, - ] = await Promise.all([ - getSurvey(params.surveyId), - getProjectByEnvironmentId(params.environmentId), - getEnvironment(params.environmentId), - getActionClasses(params.environmentId), - getContactAttributeKeys(params.environmentId), - getResponseCountBySurveyId(params.surveyId), - getOrganizationByEnvironmentId(params.environmentId), - getServerSession(authOptions), - getSegments(params.environmentId), - ]); - - if (!session) { - throw new Error(t("common.session_not_found")); - } - - if (!organization) { - throw new Error(t("common.organization_not_found")); - } - - if (!project) { - throw new Error(t("common.project_not_found")); - } - - const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); - const { isMember } = getAccessFlags(currentUserMembership?.role); - - const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id); - - const { hasReadAccess } = getTeamPermissionFlags(projectPermission); - - const isSurveyCreationDeletionDisabled = isMember && hasReadAccess; - const locale = session.user.id ? await getUserLocale(session.user.id) : undefined; - - const isUserTargetingAllowed = await getIsContactsEnabled(); - const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); - const isSurveyFollowUpsAllowed = await getSurveyFollowUpsPermission(organization); - - const userEmail = await getUserEmail(session.user.id); - - if ( - !survey || - !environment || - !actionClasses || - !contactAttributeKeys || - !project || - !userEmail || - isSurveyCreationDeletionDisabled - ) { - return ; - } - - const isCxMode = searchParams.mode === "cx"; - - return ( - - ); -}; - -export default Page; +export default SurveyEditorPage; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/loading.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/loading.tsx index db63a377b1..dcdda12a17 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/loading.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/loading.tsx @@ -1,7 +1,3 @@ -import { LoadingSpinner } from "@/modules/ui/components/loading-spinner"; +import { SurveyTemplatesLoading } from "@/modules/survey/templates/loading"; -const Loading = () => { - return ; -}; - -export default Loading; +export default SurveyTemplatesLoading; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/page.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/page.tsx index 6db6badc13..811e0caf02 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/page.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/page.tsx @@ -1,84 +1,3 @@ -import { authOptions } from "@/modules/auth/lib/authOptions"; -import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; -import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; -import { getTranslate } from "@/tolgee/server"; -import { getServerSession } from "next-auth"; -import { redirect } from "next/navigation"; -import { getEnvironment } from "@formbricks/lib/environment/service"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; -import { getUser } from "@formbricks/lib/user/service"; -import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project"; -import { TTemplateRole } from "@formbricks/types/templates"; -import { TemplateContainerWithPreview } from "./components/TemplateContainer"; +import { SurveyTemplatesPage } from "@/modules/survey/templates/page"; -interface SurveyTemplateProps { - params: Promise<{ - environmentId: string; - }>; - searchParams: Promise<{ - channel?: TProjectConfigChannel; - industry?: TProjectConfigIndustry; - role?: TTemplateRole; - }>; -} - -const Page = async (props: SurveyTemplateProps) => { - const searchParams = await props.searchParams; - const params = await props.params; - const t = await getTranslate(); - const session = await getServerSession(authOptions); - const environmentId = params.environmentId; - - if (!session) { - throw new Error(t("common.session_not_found")); - } - - const [user, environment, project] = await Promise.all([ - getUser(session.user.id), - getEnvironment(environmentId), - getProjectByEnvironmentId(environmentId), - ]); - - if (!user) { - throw new Error(t("common.user_not_found")); - } - - if (!project) { - throw new Error(t("common.project_not_found")); - } - - if (!environment) { - throw new Error(t("common.environment_not_found")); - } - const currentUserMembership = await getMembershipByUserIdOrganizationId( - session?.user.id, - project.organizationId - ); - const { isMember } = getAccessFlags(currentUserMembership?.role); - - const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id); - const { hasReadAccess } = getTeamPermissionFlags(projectPermission); - - const isReadOnly = isMember && hasReadAccess; - if (isReadOnly) { - return redirect(`/environments/${environment.id}/surveys`); - } - - const prefilledFilters = [project.config.channel, project.config.industry, searchParams.role ?? null]; - - return ( - - ); -}; - -export default Page; +export default SurveyTemplatesPage; diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts index 847066d687..850f6ab965 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts @@ -46,7 +46,7 @@ export const createProjectAction = authenticatedActionClient throw new Error("Organization not found"); } - const organizationProjectsLimit = await getOrganizationProjectsLimit(organization); + const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits); const organizationProjectsCount = await getOrganizationProjectsCount(organization.id); if (organizationProjectsCount >= organizationProjectsLimit) { @@ -54,7 +54,7 @@ export const createProjectAction = authenticatedActionClient } if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) { - const canDoRoleManagement = await getRoleManagementPermission(organization); + const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); if (!canDoRoleManagement) { throw new OperationNotAllowedError("You do not have permission to manage roles"); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.tsx index 1fa8800b3a..7f18919835 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.tsx @@ -1,7 +1,7 @@ "use client"; -import { createActionClassAction } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { createActionClassAction } from "@/modules/survey/editor/actions"; import { Button } from "@/modules/ui/components/button"; import { ErrorComponent } from "@/modules/ui/components/error-component"; import { Label } from "@/modules/ui/components/label"; diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx index b52b16859a..635f94c10a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { CreateNewActionTab } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab"; +import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab"; import { Button } from "@/modules/ui/components/button"; import { Modal } from "@/modules/ui/components/modal"; import { useTranslate } from "@tolgee/react"; diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx index d7fb47027d..48db282805 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx @@ -80,7 +80,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En ]); } - const organizationProjectsLimit = await getOrganizationProjectsLimit(organization); + const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits); return (
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/lib/contact-attribute-key.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/lib/contact-attribute-key.ts deleted file mode 100644 index 2726b3979b..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/lib/contact-attribute-key.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key"; -import { cache as reactCache } from "react"; -import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; - -export const getContactAttributeKeys = reactCache( - (environmentId: string): Promise => - cache( - async () => { - return await prisma.contactAttributeKey.findMany({ - where: { environmentId }, - }); - }, - [`getContactAttributeKeys-integrations-${environmentId}`], - { - tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)], - } - )() -); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx index 374d3d064c..4f6a177dd7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx @@ -39,7 +39,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => { const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); const { isOwner, isManager } = getAccessFlags(currentUserMembership?.role); const isMultiOrgEnabled = await getIsMultiOrgEnabled(); - const hasWhiteLabelPermission = await getWhiteLabelPermission(organization); + const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan); const isDeleteDisabled = !isOwner || !isMultiOrgEnabled; const currentUserRole = currentUserMembership?.role; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx index f36928adde..d087986bfb 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx @@ -71,7 +71,10 @@ const Page = async (props) => { const isReadOnly = isMember && hasReadAccess; - const isAIEnabled = await getIsAIEnabled(organization); + const isAIEnabled = await getIsAIEnabled({ + isAIEnabled: organization.isAIEnabled, + billing: organization.billing, + }); const shouldGenerateInsights = needsInsightsGeneration(survey); const locale = await findMatchingLocale(); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx index 5cd3455ce9..6557fe7643 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx @@ -79,7 +79,10 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv // I took this out cause it's cloud only right? // const { active: isEnterpriseEdition } = await getEnterpriseLicense(); - const isAIEnabled = await getIsAIEnabled(organization); + const isAIEnabled = await getIsAIEnabled({ + isAIEnabled: organization.isAIEnabled, + billing: organization.billing, + }); const shouldGenerateInsights = needsInsightsGeneration(survey); return ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts index ecbc6da038..4e1336da47 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts @@ -4,7 +4,7 @@ import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions"; -import { getSurveyFollowUpsPermission } from "@/modules/survey-follow-ups/lib/utils"; +import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; import { z } from "zod"; import { getOrganization } from "@formbricks/lib/organization/service"; import { getResponseDownloadUrl, getResponseFilteringValues } from "@formbricks/lib/response/service"; @@ -95,7 +95,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise => - cache( - async () => { - validateInputs([environmentId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]); - - try { - if (filterCriteria?.sortBy === "relevance") { - // Call the sortByRelevance function - return await getSurveysSortedByRelevance(environmentId, limit, offset ?? 0, filterCriteria); - } - - // Fetch surveys normally with pagination and include response count - const surveysPrisma = await prisma.survey.findMany({ - where: { - environmentId, - ...buildWhereClause(filterCriteria), - }, - select: surveySelect, - orderBy: buildOrderByClause(filterCriteria?.sortBy), - take: limit, - skip: offset, - }); - - return surveysPrisma.map((survey) => { - return { - ...survey, - responseCount: survey._count.responses, - }; - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - console.error(error); - throw new DatabaseError(error.message); - } - throw error; - } - }, - [`surveyList-getSurveys-${environmentId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`], - { - tags: [ - surveyCache.tag.byEnvironmentId(environmentId), - responseCache.tag.byEnvironmentId(environmentId), - ], - } - )() -); - -export const getSurveysSortedByRelevance = reactCache( - async ( - environmentId: string, - limit?: number, - offset?: number, - filterCriteria?: TSurveyFilterCriteria - ): Promise => - cache( - async () => { - validateInputs([environmentId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]); - - try { - let surveys: TSurvey[] = []; - const inProgressSurveyCount = await getInProgressSurveyCount(environmentId, filterCriteria); - - // Fetch surveys that are in progress first - const inProgressSurveys = - offset && offset > inProgressSurveyCount - ? [] - : await prisma.survey.findMany({ - where: { - environmentId, - status: "inProgress", - ...buildWhereClause(filterCriteria), - }, - select: surveySelect, - orderBy: buildOrderByClause("updatedAt"), - take: limit, - skip: offset, - }); - - surveys = inProgressSurveys.map((survey) => { - return { - ...survey, - responseCount: survey._count.responses, - }; - }); - - // Determine if additional surveys are needed - if (offset !== undefined && limit && inProgressSurveys.length < limit) { - const remainingLimit = limit - inProgressSurveys.length; - const newOffset = Math.max(0, offset - inProgressSurveyCount); - const additionalSurveys = await prisma.survey.findMany({ - where: { - environmentId, - status: { not: "inProgress" }, - ...buildWhereClause(filterCriteria), - }, - select: surveySelect, - orderBy: buildOrderByClause("updatedAt"), - take: remainingLimit, - skip: newOffset, - }); - - surveys = [ - ...surveys, - ...additionalSurveys.map((survey) => { - return { - ...survey, - responseCount: survey._count.responses, - }; - }), - ]; - } - - return surveys; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - console.error(error); - throw new DatabaseError(error.message); - } - throw error; - } - }, - [ - `surveyList-getSurveysSortedByRelevance-${environmentId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`, - ], - { - tags: [ - surveyCache.tag.byEnvironmentId(environmentId), - responseCache.tag.byEnvironmentId(environmentId), - ], - } - )() -); - -export const getSurvey = reactCache( - async (surveyId: string): Promise => - cache( - async () => { - validateInputs([surveyId, ZId]); - - let surveyPrisma; - try { - surveyPrisma = await prisma.survey.findUnique({ - where: { - id: surveyId, - }, - select: surveySelect, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - console.error(error); - throw new DatabaseError(error.message); - } - throw error; - } - - if (!surveyPrisma) { - return null; - } - - return { ...surveyPrisma, responseCount: surveyPrisma?._count.responses }; - }, - [`surveyList-getSurvey-${surveyId}`], - { - tags: [surveyCache.tag.byId(surveyId), responseCache.tag.bySurveyId(surveyId)], - } - )() -); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/loading.tsx index 8b930267c5..7c082be678 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/loading.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/loading.tsx @@ -1,31 +1,3 @@ -"use client"; +import { SurveyListLoading } from "@/modules/survey/list/loading"; -import { SurveyLoading } from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyLoading"; -import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; -import { PageHeader } from "@/modules/ui/components/page-header"; -import { useTranslate } from "@tolgee/react"; - -const Loading = () => { - const { t } = useTranslate(); - return ( - - -
-
-
- {[1, 2, 3].map((i) => ( -
- ))} -
-
-
-
-
-
-
- -
- ); -}; - -export default Loading; +export default SurveyListLoading; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/page.tsx index d0aec16749..d5e5de169f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/page.tsx @@ -1,144 +1,4 @@ -import { SurveysList } from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyList"; -import { authOptions } from "@/modules/auth/lib/authOptions"; -import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; -import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; -import { TemplateList } from "@/modules/surveys/components/TemplateList"; -import { Button } from "@/modules/ui/components/button"; -import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; -import { PageHeader } from "@/modules/ui/components/page-header"; -import { getTranslate } from "@/tolgee/server"; -import { PlusIcon } from "lucide-react"; -import { Metadata, NextPage } from "next"; -import { getServerSession } from "next-auth"; -import Link from "next/link"; -import { redirect } from "next/navigation"; -import { SURVEYS_PER_PAGE, WEBAPP_URL } from "@formbricks/lib/constants"; -import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; -import { getSurveyCount } from "@formbricks/lib/survey/service"; -import { getUser } from "@formbricks/lib/user/service"; -import { findMatchingLocale } from "@formbricks/lib/utils/locale"; -import { TTemplateRole } from "@formbricks/types/templates"; +import { SurveysPage, metadata } from "@/modules/survey/list/page"; -export const metadata: Metadata = { - title: "Your Surveys", -}; - -interface SurveyTemplateProps { - params: Promise<{ - environmentId: string; - }>; - searchParams: Promise<{ - role?: TTemplateRole; - }>; -} - -const SurveysPage = async ({ params: paramsProps, searchParams: searchParamsProps }: SurveyTemplateProps) => { - const searchParams = await searchParamsProps; - const params = await paramsProps; - - const session = await getServerSession(authOptions); - const project = await getProjectByEnvironmentId(params.environmentId); - const organization = await getOrganizationByEnvironmentId(params.environmentId); - const t = await getTranslate(); - if (!session) { - throw new Error(t("common.session_not_found")); - } - - const user = await getUser(session.user.id); - if (!user) { - throw new Error(t("common.user_not_found")); - } - - if (!project) { - throw new Error(t("common.project_not_found")); - } - - if (!organization) { - throw new Error(t("common.organization_not_found")); - } - - const prefilledFilters = [project?.config.channel, project.config.industry, searchParams.role ?? null]; - - const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); - const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role); - - const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id); - const { hasReadAccess } = getTeamPermissionFlags(projectPermission); - - const isReadOnly = isMember && hasReadAccess; - - if (isBilling) { - return redirect(`/environments/${params.environmentId}/settings/billing`); - } - - const environment = await getEnvironment(params.environmentId); - if (!environment) { - throw new Error(t("common.environment_not_found")); - } - - const surveyCount = await getSurveyCount(params.environmentId); - - const environments = await getEnvironments(project.id); - const otherEnvironment = environments.find((e) => e.type !== environment.type)!; - - const currentProjectChannel = project.config.channel ?? null; - const locale = await findMatchingLocale(); - const CreateSurveyButton = () => { - return ( - - ); - }; - - return ( - - {surveyCount > 0 ? ( - <> - : } /> - - - ) : isReadOnly ? ( - <> -

- {t("environments.surveys.no_surveys_created_yet")} -

- -

- {t("environments.surveys.read_only_user_not_allowed_to_create_survey_warning")} -

- - ) : ( - <> -

- {t("environments.surveys.all_set_time_to_create_first_survey")} -

- - - )} -
- ); -}; - -export default SurveysPage as NextPage; +export { metadata }; +export default SurveysPage; diff --git a/apps/web/app/[shortUrlId]/page.tsx b/apps/web/app/[shortUrlId]/page.tsx index 306a288127..eb26877f77 100644 --- a/apps/web/app/[shortUrlId]/page.tsx +++ b/apps/web/app/[shortUrlId]/page.tsx @@ -1,4 +1,4 @@ -import { getMetadataForLinkSurvey } from "@/app/s/[surveyId]/metadata"; +import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata"; import type { Metadata } from "next"; import { notFound, redirect } from "next/navigation"; import { getShortUrl } from "@formbricks/lib/shortUrl/service"; diff --git a/apps/web/app/api/(internal)/pipeline/route.ts b/apps/web/app/api/(internal)/pipeline/route.ts index 204177b477..c2739e4877 100644 --- a/apps/web/app/api/(internal)/pipeline/route.ts +++ b/apps/web/app/api/(internal)/pipeline/route.ts @@ -6,7 +6,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator"; import { webhookCache } from "@/lib/cache/webhook"; import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils"; import { sendResponseFinishedEmail } from "@/modules/email"; -import { getSurveyFollowUpsPermission } from "@/modules/survey-follow-ups/lib/utils"; +import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; import { PipelineTriggers, Webhook } from "@prisma/client"; import { headers } from "next/headers"; import { prisma } from "@formbricks/database"; @@ -164,7 +164,7 @@ export const POST = async (request: Request) => { }); // send follow up emails - const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization); + const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan); if (surveyFollowUpsPermission) { await sendSurveyFollowUps(survey, response, organization); @@ -197,7 +197,10 @@ export const POST = async (request: Request) => { if (hasSurveyOpenTextQuestions) { const isAICofigured = IS_AI_CONFIGURED; if (hasSurveyOpenTextQuestions && isAICofigured) { - const isAIEnabled = await getIsAIEnabled(organization); + const isAIEnabled = await getIsAIEnabled({ + isAIEnabled: organization.isAIEnabled, + billing: organization.billing, + }); if (isAIEnabled) { for (const question of survey.questions) { diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/survey.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/survey.ts index 686f94c927..e771f470e7 100644 --- a/apps/web/app/api/v1/client/[environmentId]/environment/lib/survey.ts +++ b/apps/web/app/api/v1/client/[environmentId]/environment/lib/survey.ts @@ -1,9 +1,9 @@ +import { transformPrismaSurvey } from "@/modules/survey/lib/utils"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; import { cache } from "@formbricks/lib/cache"; import { surveyCache } from "@formbricks/lib/survey/cache"; -import { transformPrismaSurvey } from "@formbricks/lib/survey/utils"; import { validateInputs } from "@formbricks/lib/utils/validate"; import { ZId } from "@formbricks/types/common"; import { DatabaseError } from "@formbricks/types/errors"; diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts b/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts index bdc0af48ea..8fc2df44bc 100644 --- a/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts @@ -108,7 +108,7 @@ export const POST = async (req: NextRequest, context: Context): Promise { + validateInputs([surveyId, z.string().cuid2()]); + + try { + const deletedSurvey = await prisma.survey.delete({ + where: { + id: surveyId, + }, + include: { + segment: true, + triggers: { + include: { + actionClass: true, + }, + }, + }, + }); + + if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) { + const deletedSegment = await prisma.segment.delete({ + where: { + id: deletedSurvey.segment.id, + }, + }); + + if (deletedSegment) { + segmentCache.revalidate({ + id: deletedSegment.id, + environmentId: deletedSurvey.environmentId, + }); + } + } + + responseCache.revalidate({ + surveyId, + environmentId: deletedSurvey.environmentId, + }); + surveyCache.revalidate({ + id: deletedSurvey.id, + environmentId: deletedSurvey.environmentId, + resultShareKey: deletedSurvey.resultShareKey ?? undefined, + }); + + if (deletedSurvey.segment?.id) { + segmentCache.revalidate({ + id: deletedSurvey.segment.id, + environmentId: deletedSurvey.environmentId, + }); + } + + // Revalidate public triggers by actionClassId + deletedSurvey.triggers.forEach((trigger) => { + surveyCache.revalidate({ + actionClassId: trigger.actionClass.id, + }); + }); + + return deletedSurvey; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + + throw error; + } +}; diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts index f46279f8ec..4c44d923ad 100644 --- a/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts +++ b/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts @@ -1,10 +1,11 @@ import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; +import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; -import { getSurveyFollowUpsPermission } from "@/modules/survey-follow-ups/lib/utils"; +import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { deleteSurvey, getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; +import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; import { TSurvey, ZSurveyUpdateInput } from "@formbricks/types/surveys/types"; const fetchAndAuthorizeSurvey = async (authentication: any, surveyId: string): Promise => { @@ -95,14 +96,14 @@ export const PUT = async ( } if (surveyUpdate.followUps && surveyUpdate.followUps.length) { - const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization); + const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan); if (!isSurveyFollowUpsEnabled) { return responses.forbiddenResponse("Survey follow ups are not enabled for this organization"); } } if (surveyUpdate.languages && surveyUpdate.languages.length) { - const isMultiLanguageEnabled = await getMultiLanguagePermission(organization); + const isMultiLanguageEnabled = await getMultiLanguagePermission(organization.billing.plan); if (!isMultiLanguageEnabled) { return responses.forbiddenResponse("Multi language is not enabled for this organization"); } diff --git a/apps/web/app/api/v1/management/surveys/route.ts b/apps/web/app/api/v1/management/surveys/route.ts index 6ab9c55b97..029c5c29fc 100644 --- a/apps/web/app/api/v1/management/surveys/route.ts +++ b/apps/web/app/api/v1/management/surveys/route.ts @@ -2,7 +2,7 @@ import { authenticateRequest } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; -import { getSurveyFollowUpsPermission } from "@/modules/survey-follow-ups/lib/utils"; +import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { createSurvey, getSurveys } from "@formbricks/lib/survey/service"; import { DatabaseError } from "@formbricks/types/errors"; @@ -59,14 +59,14 @@ export const POST = async (request: Request): Promise => { const surveyData = { ...inputValidation.data, environmentId: undefined }; if (surveyData.followUps?.length) { - const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization); + const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan); if (!isSurveyFollowUpsEnabled) { return responses.forbiddenResponse("Survey follow ups are not enabled allowed for this organization"); } } if (surveyData.languages && surveyData.languages.length) { - const isMultiLanguageEnabled = await getMultiLanguagePermission(organization); + const isMultiLanguageEnabled = await getMultiLanguagePermission(organization.billing.plan); if (!isMultiLanguageEnabled) { return responses.forbiddenResponse("Multi language is not enabled for this organization"); } diff --git a/apps/web/app/s/[surveyId]/layout.tsx b/apps/web/app/s/[surveyId]/layout.tsx index 295b795ac2..b8ad2cf34d 100644 --- a/apps/web/app/s/[surveyId]/layout.tsx +++ b/apps/web/app/s/[surveyId]/layout.tsx @@ -1,15 +1,5 @@ -import { Viewport } from "next"; +import { LinkSurveyLayout, viewport } from "@/modules/survey/link/layout"; -export const viewport: Viewport = { - width: "device-width", - initialScale: 1.0, - maximumScale: 1.0, - userScalable: false, - viewportFit: "contain", -}; +export { viewport }; -const SurveyLayout = ({ children }) => { - return
{children}
; -}; - -export default SurveyLayout; +export default LinkSurveyLayout; diff --git a/apps/web/app/s/[surveyId]/lib/contact-attribute-key.ts b/apps/web/app/s/[surveyId]/lib/contact-attribute-key.ts deleted file mode 100644 index cb8e293727..0000000000 --- a/apps/web/app/s/[surveyId]/lib/contact-attribute-key.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key"; -import { cache as reactCache } from "react"; -import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; - -export const getContactAttributeKeys = reactCache( - (environmentId: string): Promise => - cache( - async () => { - return await prisma.contactAttributeKey.findMany({ - where: { environmentId }, - }); - }, - [`getContactAttributeKeys-survey-page-${environmentId}`], - { - tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)], - } - )() -); diff --git a/apps/web/app/s/[surveyId]/loading.tsx b/apps/web/app/s/[surveyId]/loading.tsx index 617f3d5493..a312961734 100644 --- a/apps/web/app/s/[surveyId]/loading.tsx +++ b/apps/web/app/s/[surveyId]/loading.tsx @@ -1,12 +1,3 @@ -const Loading = () => { - return ( -
-
-
-
-
-
- ); -}; +import { LinkSurveyLoading } from "@/modules/survey/link/loading"; -export default Loading; +export default LinkSurveyLoading; diff --git a/apps/web/app/s/[surveyId]/not-found.tsx b/apps/web/app/s/[surveyId]/not-found.tsx index f27ffae6c1..845c9b416b 100644 --- a/apps/web/app/s/[surveyId]/not-found.tsx +++ b/apps/web/app/s/[surveyId]/not-found.tsx @@ -1,29 +1,3 @@ -import { Button } from "@/modules/ui/components/button"; -import { HelpCircleIcon } from "lucide-react"; -import { StaticImport } from "next/dist/shared/lib/get-img-props"; -import Image from "next/image"; -import Link from "next/link"; -import footerLogo from "./lib/footerlogo.svg"; +import { LinkSurveyNotFound } from "@/modules/survey/link/not-found"; -const NotFound = () => { - return ( -
-
-
- , -

Survey not found.

-

There is no survey with this ID.

- -
-
- - Brand logo - -
-
- ); -}; - -export default NotFound; +export default LinkSurveyNotFound; diff --git a/apps/web/app/s/[surveyId]/page.tsx b/apps/web/app/s/[surveyId]/page.tsx index 836038e057..145e3fc249 100644 --- a/apps/web/app/s/[surveyId]/page.tsx +++ b/apps/web/app/s/[surveyId]/page.tsx @@ -1,189 +1,4 @@ -import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys"; -import { LinkSurvey } from "@/app/s/[surveyId]/components/link-survey"; -import { PinScreen } from "@/app/s/[surveyId]/components/pin-screen"; -import { SurveyInactive } from "@/app/s/[surveyId]/components/survey-inactive"; -import { getMetadataForLinkSurvey } from "@/app/s/[surveyId]/metadata"; -import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; -import type { Metadata } from "next"; -import { notFound } from "next/navigation"; -import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@formbricks/lib/constants"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; -import { getResponseBySingleUseId, getResponseCountBySurveyId } from "@formbricks/lib/response/service"; -import { getSurvey } from "@formbricks/lib/survey/service"; -import { findMatchingLocale } from "@formbricks/lib/utils/locale"; -import { ZId } from "@formbricks/types/common"; -import { TResponse } from "@formbricks/types/responses"; -import { getEmailVerificationDetails } from "./lib/helpers"; +import { LinkSurveyPage, generateMetadata } from "@/modules/survey/link/page"; -interface LinkSurveyPageProps { - params: Promise<{ - surveyId: string; - }>; - searchParams: Promise<{ - suId?: string; - verify?: string; - lang?: string; - embed?: string; - preview?: string; - }>; -} - -export const generateMetadata = async (props: LinkSurveyPageProps): Promise => { - const params = await props.params; - const validId = ZId.safeParse(params.surveyId); - if (!validId.success) { - notFound(); - } - - return getMetadataForLinkSurvey(params.surveyId); -}; - -const Page = async (props: LinkSurveyPageProps) => { - const searchParams = await props.searchParams; - const params = await props.params; - const validId = ZId.safeParse(params.surveyId); - if (!validId.success) { - notFound(); - } - const isPreview = searchParams.preview === "true"; - const survey = await getSurvey(params.surveyId); - const locale = await findMatchingLocale(); - const suId = searchParams.suId; - const langParam = searchParams.lang; //can either be language code or alias - const isSingleUseSurvey = survey?.singleUse?.enabled; - const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted; - const isEmbed = searchParams.embed === "true"; - if (!survey || survey.type !== "link" || survey.status === "draft") { - notFound(); - } - - const organization = await getOrganizationByEnvironmentId(survey.environmentId); - if (!organization) { - throw new Error("Organization not found"); - } - const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); - - if (survey.status !== "inProgress" && !isPreview) { - return ( - - ); - } - - let singleUseId: string | undefined = undefined; - if (isSingleUseSurvey) { - // check if the single use id is present for single use surveys - if (!suId) { - return ; - } - - // if encryption is enabled, validate the single use id - let validatedSingleUseId: string | undefined = undefined; - if (isSingleUseSurveyEncrypted) { - validatedSingleUseId = validateSurveySingleUseId(suId); - if (!validatedSingleUseId) { - return ; - } - } - // if encryption is disabled, use the suId as is - singleUseId = validatedSingleUseId ?? suId; - } - - let singleUseResponse: TResponse | undefined = undefined; - if (isSingleUseSurvey) { - try { - singleUseResponse = singleUseId - ? ((await getResponseBySingleUseId(survey.id, singleUseId)) ?? undefined) - : undefined; - } catch (error) { - singleUseResponse = undefined; - } - } - - // verify email: Check if the survey requires email verification - let emailVerificationStatus = ""; - let verifiedEmail: string | undefined = undefined; - - if (survey.isVerifyEmailEnabled) { - const token = searchParams.verify; - - if (token) { - const emailVerificationDetails = await getEmailVerificationDetails(survey.id, token); - emailVerificationStatus = emailVerificationDetails.status; - verifiedEmail = emailVerificationDetails.email; - } - } - - // get project and person - const project = await getProjectByEnvironmentId(survey.environmentId); - if (!project) { - throw new Error("Project not found"); - } - - const getLanguageCode = (): string => { - if (!langParam || !isMultiLanguageAllowed) return "default"; - else { - const selectedLanguage = survey.languages.find((surveyLanguage) => { - return ( - surveyLanguage.language.code === langParam.toLowerCase() || - surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase() - ); - }); - if (selectedLanguage?.default || !selectedLanguage?.enabled) { - return "default"; - } - return selectedLanguage.language.code; - } - }; - - const languageCode = getLanguageCode(); - - const isSurveyPinProtected = Boolean(survey.pin); - const responseCount = await getResponseCountBySurveyId(survey.id); - - if (isSurveyPinProtected) { - return ( - - ); - } - - return ( - - ); -}; - -export default Page; +export { generateMetadata }; +export default LinkSurveyPage; diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx index 83c27c99ed..69bb2f09d8 100644 --- a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx +++ b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx @@ -1,7 +1,7 @@ "use client"; -import { generateSingleUseIdAction } from "@/app/(app)/environments/[environmentId]/surveys/actions"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { generateSingleUseIdAction } from "@/modules/survey/list/actions"; import { Button } from "@/modules/ui/components/button"; import { useTranslate } from "@tolgee/react"; import { Copy, RefreshCcw, SquareArrowOutUpRight } from "lucide-react"; diff --git a/apps/web/modules/ee/insights/actions.ts b/apps/web/modules/ee/insights/actions.ts index 154bbf1ce8..2c19af3984 100644 --- a/apps/web/modules/ee/insights/actions.ts +++ b/apps/web/modules/ee/insights/actions.ts @@ -18,7 +18,10 @@ export const checkAIPermission = async (organizationId: string) => { throw new Error("Organization not found"); } - const isAIEnabled = await getIsAIEnabled(organization); + const isAIEnabled = await getIsAIEnabled({ + isAIEnabled: organization.isAIEnabled, + billing: organization.billing, + }); if (!isAIEnabled) { throw new OperationNotAllowedError("AI is not enabled for this organization"); diff --git a/apps/web/modules/ee/insights/experience/components/templates-card.tsx b/apps/web/modules/ee/insights/experience/components/templates-card.tsx index 4b6b9df6d8..a593cb2cc7 100644 --- a/apps/web/modules/ee/insights/experience/components/templates-card.tsx +++ b/apps/web/modules/ee/insights/experience/components/templates-card.tsx @@ -1,16 +1,16 @@ "use client"; -import { TemplateList } from "@/modules/surveys/components/TemplateList"; +import { TemplateList } from "@/modules/survey/components/template-list"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/modules/ui/components/card"; +import { Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { TEnvironment } from "@formbricks/types/environment"; -import { TProject } from "@formbricks/types/project"; import { TTemplateFilter } from "@formbricks/types/templates"; import { TUser } from "@formbricks/types/user"; interface TemplatesCardProps { environment: TEnvironment; - project: TProject; + project: Project; user: TUser; prefilledFilters: TTemplateFilter[]; } @@ -25,10 +25,10 @@ export const TemplatesCard = ({ environment, project, user, prefilledFilters }: diff --git a/apps/web/modules/ee/insights/experience/page.tsx b/apps/web/modules/ee/insights/experience/page.tsx index 4f06660bbb..a3dde9ee91 100644 --- a/apps/web/modules/ee/insights/experience/page.tsx +++ b/apps/web/modules/ee/insights/experience/page.tsx @@ -50,7 +50,10 @@ export const ExperiencePage = async (props) => { notFound(); } - const isAIEnabled = await getIsAIEnabled(organization); + const isAIEnabled = await getIsAIEnabled({ + isAIEnabled: organization.isAIEnabled, + billing: organization.billing, + }); if (!isAIEnabled) { notFound(); diff --git a/apps/web/modules/ee/languages/page.tsx b/apps/web/modules/ee/languages/page.tsx index e18143e141..19a78c37d4 100644 --- a/apps/web/modules/ee/languages/page.tsx +++ b/apps/web/modules/ee/languages/page.tsx @@ -34,12 +34,12 @@ export const LanguagesPage = async (props: { params: Promise<{ environmentId: st throw new Error(t("common.organization_not_found")); } - const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); + const isMultiLanguageAllowed = await getMultiLanguagePermission(organization.billing.plan); if (!isMultiLanguageAllowed) { notFound(); } - const canDoRoleManagement = await getRoleManagementPermission(organization); + const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); const session = await getServerSession(authOptions); diff --git a/apps/web/modules/ee/license-check/lib/utils.ts b/apps/web/modules/ee/license-check/lib/utils.ts index 7ea33654ed..eaa620afe7 100644 --- a/apps/web/modules/ee/license-check/lib/utils.ts +++ b/apps/web/modules/ee/license-check/lib/utils.ts @@ -3,6 +3,7 @@ import { TEnterpriseLicenseDetails, TEnterpriseLicenseFeatures, } from "@/modules/ee/license-check/types/enterprise-license"; +import { Organization } from "@prisma/client"; import { HttpsProxyAgent } from "https-proxy-agent"; import { after } from "next/server"; import fetch from "node-fetch"; @@ -18,7 +19,6 @@ import { } from "@formbricks/lib/constants"; import { env } from "@formbricks/lib/env"; import { hashString } from "@formbricks/lib/hashString"; -import { TOrganization, TOrganizationBillingPlan } from "@formbricks/types/organizations"; const hashedKey = ENTERPRISE_LICENSE_KEY ? hashString(ENTERPRISE_LICENSE_KEY) : undefined; const PREVIOUS_RESULTS_CACHE_TAG_KEY = `getPreviousResult-${hashedKey}` as const; @@ -261,14 +261,16 @@ export const fetchLicense = reactCache( )() ); -export const getRemoveBrandingPermission = async (organization: TOrganization): Promise => { +export const getRemoveBrandingPermission = async ( + billingPlan: Organization["billing"]["plan"] +): Promise => { if (E2E_TESTING) { const previousResult = await fetchLicenseForE2ETesting(); return previousResult?.features?.removeBranding ?? false; } if (IS_FORMBRICKS_CLOUD && (await getEnterpriseLicense()).active) { - return organization.billing.plan !== PROJECT_FEATURE_KEYS.FREE; + return billingPlan !== PROJECT_FEATURE_KEYS.FREE; } else { const licenseFeatures = await getLicenseFeatures(); if (!licenseFeatures) return false; @@ -277,14 +279,16 @@ export const getRemoveBrandingPermission = async (organization: TOrganization): } }; -export const getWhiteLabelPermission = async (organization: TOrganization): Promise => { +export const getWhiteLabelPermission = async ( + billingPlan: Organization["billing"]["plan"] +): Promise => { if (E2E_TESTING) { const previousResult = await fetchLicenseForE2ETesting(); return previousResult?.features?.whitelabel ?? false; } if (IS_FORMBRICKS_CLOUD && (await getEnterpriseLicense()).active) { - return organization.billing.plan !== PROJECT_FEATURE_KEYS.FREE; + return billingPlan !== PROJECT_FEATURE_KEYS.FREE; } else { const licenseFeatures = await getLicenseFeatures(); if (!licenseFeatures) return false; @@ -293,36 +297,36 @@ export const getWhiteLabelPermission = async (organization: TOrganization): Prom } }; -export const getRoleManagementPermission = async (organization: TOrganization): Promise => { +export const getRoleManagementPermission = async ( + billingPlan: Organization["billing"]["plan"] +): Promise => { if (E2E_TESTING) { const previousResult = await fetchLicenseForE2ETesting(); return previousResult && previousResult.active !== null ? previousResult.active : false; } if (IS_FORMBRICKS_CLOUD) - return ( - organization.billing.plan === PROJECT_FEATURE_KEYS.SCALE || - organization.billing.plan === PROJECT_FEATURE_KEYS.ENTERPRISE - ); + return billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE; else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active; return false; }; -export const getBiggerUploadFileSizePermission = async (organization: TOrganization): Promise => { - if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PROJECT_FEATURE_KEYS.FREE; +export const getBiggerUploadFileSizePermission = async ( + billingPlan: Organization["billing"]["plan"] +): Promise => { + if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE; else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active; return false; }; -export const getMultiLanguagePermission = async (organization: TOrganization): Promise => { +export const getMultiLanguagePermission = async ( + billingPlan: Organization["billing"]["plan"] +): Promise => { if (E2E_TESTING) { const previousResult = await fetchLicenseForE2ETesting(); return previousResult && previousResult.active !== null ? previousResult.active : false; } if (IS_FORMBRICKS_CLOUD) - return ( - organization.billing.plan === PROJECT_FEATURE_KEYS.SCALE || - organization.billing.plan === PROJECT_FEATURE_KEYS.ENTERPRISE - ); + return billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE; else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active; return false; }; @@ -367,7 +371,7 @@ export const getIsSSOEnabled = async (): Promise => { return licenseFeatures.sso; }; -export const getIsOrganizationAIReady = async (billingPlan: TOrganizationBillingPlan) => { +export const getIsOrganizationAIReady = async (billingPlan: Organization["billing"]["plan"]) => { if (!IS_AI_CONFIGURED) return false; if (E2E_TESTING) { const previousResult = await fetchLicenseForE2ETesting(); @@ -382,11 +386,13 @@ export const getIsOrganizationAIReady = async (billingPlan: TOrganizationBilling return Boolean(license.features?.ai); }; -export const getIsAIEnabled = async (organization: TOrganization) => { +export const getIsAIEnabled = async (organization: Pick) => { return organization.isAIEnabled && (await getIsOrganizationAIReady(organization.billing.plan)); }; -export const getOrganizationProjectsLimit = async (organization: TOrganization): Promise => { +export const getOrganizationProjectsLimit = async ( + limits: Organization["billing"]["limits"] +): Promise => { if (E2E_TESTING) { const previousResult = await fetchLicenseForE2ETesting(); return previousResult && previousResult.features ? (previousResult.features.projects ?? Infinity) : 3; @@ -395,7 +401,7 @@ export const getOrganizationProjectsLimit = async (organization: TOrganization): let limit: number; if (IS_FORMBRICKS_CLOUD && (await getEnterpriseLicense()).active) { - limit = organization.billing.limits.projects ?? Infinity; + limit = limits.projects ?? Infinity; } else { const licenseFeatures = await getLicenseFeatures(); if (!licenseFeatures) { diff --git a/apps/web/modules/ee/multi-language-surveys/components/default-language-select.tsx b/apps/web/modules/ee/multi-language-surveys/components/default-language-select.tsx index e535949d1c..092b828a2b 100644 --- a/apps/web/modules/ee/multi-language-surveys/components/default-language-select.tsx +++ b/apps/web/modules/ee/multi-language-surveys/components/default-language-select.tsx @@ -8,15 +8,15 @@ import { SelectTrigger, SelectValue, } from "@/modules/ui/components/select"; +import { Language } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { getLanguageLabel } from "@formbricks/lib/i18n/utils"; -import type { TLanguage, TProject } from "@formbricks/types/project"; import type { ConfirmationModalProps } from "./multi-language-card"; interface DefaultLanguageSelectProps { - defaultLanguage?: TLanguage; + defaultLanguage?: Language; handleDefaultLanguageChange: (languageCode: string) => void; - project: TProject; + projectLanguages: Language[]; setConfirmationModalInfo: (confirmationModal: ConfirmationModalProps) => void; locale: string; } @@ -24,7 +24,7 @@ interface DefaultLanguageSelectProps { export function DefaultLanguageSelect({ defaultLanguage, handleDefaultLanguageChange, - project, + projectLanguages, setConfirmationModalInfo, locale, }: DefaultLanguageSelectProps) { @@ -61,7 +61,7 @@ export function DefaultLanguageSelect({ - {project.languages.map((language) => ( + {projectLanguages.map((language) => ( { return new Set(arr).size !== arr.length; }; -const validateLanguages = (languages: TLanguage[], t: TFnType) => { +const validateLanguages = (languages: Language[], t: TFnType) => { const languageCodes = languages.map((language) => language.code.toLowerCase().trim()); const languageAliases = languages .filter((language) => language.alias) @@ -71,7 +72,7 @@ const validateLanguages = (languages: TLanguage[], t: TFnType) => { export function EditLanguage({ project, locale, isReadOnly }: EditLanguageProps) { const { t } = useTranslate(); - const [languages, setLanguages] = useState(project.languages); + const [languages, setLanguages] = useState(project.languages); const [isEditing, setIsEditing] = useState(false); const [confirmationModal, setConfirmationModal] = useState({ isOpen: false, @@ -85,7 +86,14 @@ export function EditLanguage({ project, locale, isReadOnly }: EditLanguageProps) }, [project.languages]); const handleAddLanguage = () => { - const newLanguage = { id: "new", createdAt: new Date(), updatedAt: new Date(), code: "", alias: "" }; + const newLanguage = { + id: "new", + createdAt: new Date(), + updatedAt: new Date(), + code: "", + alias: "", + projectId: project.id, + }; setLanguages((prev) => [...prev, newLanguage]); setIsEditing(true); }; @@ -184,7 +192,7 @@ export function EditLanguage({ project, locale, isReadOnly }: EditLanguageProps) language={language} locale={locale} onDelete={() => handleDeleteLanguage(language.id)} - onLanguageChange={(newLanguage: TLanguage) => { + onLanguageChange={(newLanguage: Language) => { const updatedLanguages = [...languages]; updatedLanguages[index] = newLanguage; setLanguages(updatedLanguages); diff --git a/apps/web/modules/ee/multi-language-surveys/components/language-row.tsx b/apps/web/modules/ee/multi-language-surveys/components/language-row.tsx index c2733cbf86..f3da260972 100644 --- a/apps/web/modules/ee/multi-language-surveys/components/language-row.tsx +++ b/apps/web/modules/ee/multi-language-surveys/components/language-row.tsx @@ -2,16 +2,16 @@ import { Button } from "@/modules/ui/components/button"; import { Input } from "@/modules/ui/components/input"; +import { Language } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; -import type { TLanguage } from "@formbricks/types/project"; import { TUserLocale } from "@formbricks/types/user"; import { LanguageSelect } from "./language-select"; interface LanguageRowProps { - language: TLanguage; + language: Language; isEditing: boolean; index: number; - onLanguageChange: (newLanguage: TLanguage) => void; + onLanguageChange: (newLanguage: Language) => void; onDelete: () => void; locale: TUserLocale; } diff --git a/apps/web/modules/ee/multi-language-surveys/components/language-select.tsx b/apps/web/modules/ee/multi-language-surveys/components/language-select.tsx index 257edf600a..e0d558f542 100644 --- a/apps/web/modules/ee/multi-language-surveys/components/language-select.tsx +++ b/apps/web/modules/ee/multi-language-surveys/components/language-select.tsx @@ -2,18 +2,18 @@ import { Button } from "@/modules/ui/components/button"; import { Input } from "@/modules/ui/components/input"; +import { Language } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { ChevronDown } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { TIso639Language } from "@formbricks/lib/i18n/utils"; import { iso639Languages } from "@formbricks/lib/i18n/utils"; import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside"; -import type { TLanguage } from "@formbricks/types/project"; import { TUserLocale } from "@formbricks/types/user"; interface LanguageSelectProps { - language: TLanguage; - onLanguageChange: (newLanguage: TLanguage) => void; + language: Language; + onLanguageChange: (newLanguage: Language) => void; disabled: boolean; locale: TUserLocale; } diff --git a/apps/web/modules/ee/multi-language-surveys/components/language-toggle.tsx b/apps/web/modules/ee/multi-language-surveys/components/language-toggle.tsx index 193b787b61..885f74b2a2 100644 --- a/apps/web/modules/ee/multi-language-surveys/components/language-toggle.tsx +++ b/apps/web/modules/ee/multi-language-surveys/components/language-toggle.tsx @@ -2,13 +2,13 @@ import { Label } from "@/modules/ui/components/label"; import { Switch } from "@/modules/ui/components/switch"; +import { Language } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { getLanguageLabel } from "@formbricks/lib/i18n/utils"; -import type { TLanguage } from "@formbricks/types/project"; import type { TUserLocale } from "@formbricks/types/user"; interface LanguageToggleProps { - language: TLanguage; + language: Language; isChecked: boolean; onToggle: () => void; onEdit: () => void; diff --git a/apps/web/modules/ee/multi-language-surveys/components/multi-language-card.tsx b/apps/web/modules/ee/multi-language-surveys/components/multi-language-card.tsx index e7f238fa28..942cb4feb1 100644 --- a/apps/web/modules/ee/multi-language-surveys/components/multi-language-card.tsx +++ b/apps/web/modules/ee/multi-language-surveys/components/multi-language-card.tsx @@ -7,6 +7,7 @@ import { Label } from "@/modules/ui/components/label"; import { Switch } from "@/modules/ui/components/switch"; import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt"; import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { Language } from "@prisma/client"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useTranslate } from "@tolgee/react"; import { ArrowUpRight, Languages } from "lucide-react"; @@ -15,7 +16,6 @@ import type { FC } from "react"; import { useEffect, useMemo, useState } from "react"; import { cn } from "@formbricks/lib/cn"; import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils"; -import type { TLanguage, TProject } from "@formbricks/types/project"; import type { TSurvey, TSurveyLanguage, TSurveyQuestionId } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { DefaultLanguageSelect } from "./default-language-select"; @@ -23,7 +23,7 @@ import { SecondaryLanguageSelect } from "./secondary-language-select"; interface MultiLanguageCardProps { localSurvey: TSurvey; - project: TProject; + projectLanguages: Language[]; setLocalSurvey: (survey: TSurvey) => void; activeQuestionId: TSurveyQuestionId | null; setActiveQuestionId: (questionId: TSurveyQuestionId | null) => void; @@ -44,10 +44,10 @@ export interface ConfirmationModalProps { export const MultiLanguageCard: FC = ({ activeQuestionId, - project, localSurvey, setActiveQuestionId, setLocalSurvey, + projectLanguages, isMultiLanguageAllowed, isFormbricksCloud, setSelectedLanguageCode, @@ -91,7 +91,7 @@ export const MultiLanguageCard: FC = ({ setLocalSurvey(updatedSurvey as TSurvey); }; - const updateSurveyLanguages = (language: TLanguage) => { + const updateSurveyLanguages = (language: Language) => { let updatedLanguages = localSurvey.languages; const languageIndex = localSurvey.languages.findIndex( (surveyLanguage) => surveyLanguage.language.code === language.code @@ -120,7 +120,7 @@ export const MultiLanguageCard: FC = ({ }; const handleDefaultLanguageChange = (languageCode: string) => { - const language = project.languages.find((lang) => lang.code === languageCode); + const language = projectLanguages.find((lang) => lang.code === languageCode); if (language) { let languageExists = false; @@ -213,7 +213,7 @@ export const MultiLanguageCard: FC = ({ { handleActivationSwitchLogic(); @@ -245,16 +245,16 @@ export const MultiLanguageCard: FC = ({ /> ) : ( <> - {project.languages.length <= 1 && ( + {projectLanguages.length <= 1 && (
- {project.languages.length === 0 + {projectLanguages.length === 0 ? t("environments.surveys.edit.no_languages_found_add_first_one_to_get_started") : t( "environments.surveys.edit.you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations" )}
)} - {project.languages.length > 1 && ( + {projectLanguages.length > 1 && (
{isMultiLanguageAllowed && !isMultiLanguageActivated ? ( @@ -269,7 +269,7 @@ export const MultiLanguageCard: FC = ({ @@ -277,7 +277,7 @@ export const MultiLanguageCard: FC = ({ void; setActiveQuestionId: (questionId: TSurveyQuestionId) => void; localSurvey: TSurvey; - updateSurveyLanguages: (language: TLanguage) => void; + updateSurveyLanguages: (language: Language) => void; locale: TUserLocale; } export function SecondaryLanguageSelect({ - project, + projectLanguages, defaultLanguage, setSelectedLanguageCode, setActiveQuestionId, @@ -26,7 +26,7 @@ export function SecondaryLanguageSelect({ locale, }: SecondaryLanguageSelectProps) { const { t } = useTranslate(); - const isLanguageToggled = (language: TLanguage) => { + const isLanguageToggled = (language: Language) => { return localSurvey.languages.some( (surveyLanguage) => surveyLanguage.language.code === language.code && surveyLanguage.enabled ); @@ -37,7 +37,7 @@ export function SecondaryLanguageSelect({

{t("environments.surveys.edit.2_activate_translation_for_specific_languages")}:

- {project.languages + {projectLanguages .filter((lang) => lang.id !== defaultLanguage.id) .map((language) => ( { throw new ResourceNotFoundError("Organization", organizationId); } - const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); + const isMultiLanguageAllowed = await getMultiLanguagePermission(organization.billing.plan); if (!isMultiLanguageAllowed) { throw new OperationNotAllowedError("Multi language is not allowed for this organization"); diff --git a/apps/web/modules/ee/role-management/actions.ts b/apps/web/modules/ee/role-management/actions.ts index 8142652064..1586680dc4 100644 --- a/apps/web/modules/ee/role-management/actions.ts +++ b/apps/web/modules/ee/role-management/actions.ts @@ -19,7 +19,7 @@ export const checkRoleManagementPermission = async (organizationId: string) => { throw new Error("Organization not found"); } - const isRoleManagementAllowed = await getRoleManagementPermission(organization); + const isRoleManagementAllowed = await getRoleManagementPermission(organization.billing.plan); if (!isRoleManagementAllowed) { throw new OperationNotAllowedError("Role management is not allowed for this organization"); } diff --git a/apps/web/modules/ee/teams/project-teams/page.tsx b/apps/web/modules/ee/teams/project-teams/page.tsx index 3a6ea60a4b..4efeaff0b3 100644 --- a/apps/web/modules/ee/teams/project-teams/page.tsx +++ b/apps/web/modules/ee/teams/project-teams/page.tsx @@ -37,8 +37,8 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); const { isOwner, isManager } = getAccessFlags(currentUserMembership?.role); - const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); - const canDoRoleManagement = await getRoleManagementPermission(organization); + const isMultiLanguageAllowed = await getMultiLanguagePermission(organization.billing.plan); + const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); const teams = await getTeamsByProjectId(project.id); diff --git a/apps/web/modules/ee/whitelabel/email-customization/actions.ts b/apps/web/modules/ee/whitelabel/email-customization/actions.ts index 18e1e2befe..67abc9517a 100644 --- a/apps/web/modules/ee/whitelabel/email-customization/actions.ts +++ b/apps/web/modules/ee/whitelabel/email-customization/actions.ts @@ -20,7 +20,7 @@ export const checkWhiteLabelPermission = async (organizationId: string) => { throw new ResourceNotFoundError("Organization", organizationId); } - const isWhiteLabelAllowed = await getWhiteLabelPermission(organization); + const isWhiteLabelAllowed = await getWhiteLabelPermission(organization.billing.plan); if (!isWhiteLabelAllowed) { throw new OperationNotAllowedError("White label is not allowed for this organization"); diff --git a/apps/web/modules/ee/whitelabel/remove-branding/actions.ts b/apps/web/modules/ee/whitelabel/remove-branding/actions.ts index 117e4c97b7..4786a310da 100644 --- a/apps/web/modules/ee/whitelabel/remove-branding/actions.ts +++ b/apps/web/modules/ee/whitelabel/remove-branding/actions.ts @@ -46,7 +46,7 @@ export const updateProjectBrandingAction = authenticatedActionClient if (!organization) { throw new Error("Organization not found"); } - const canRemoveBranding = await getRemoveBrandingPermission(organization); + const canRemoveBranding = await getRemoveBrandingPermission(organization.billing.plan); if (parsedInput.data.inAppSurveyBranding !== undefined) { if (!canRemoveBranding) { diff --git a/apps/web/modules/ee/whitelabel/remove-branding/components/branding-settings-card.tsx b/apps/web/modules/ee/whitelabel/remove-branding/components/branding-settings-card.tsx index f30943b94f..5b83bf313f 100644 --- a/apps/web/modules/ee/whitelabel/remove-branding/components/branding-settings-card.tsx +++ b/apps/web/modules/ee/whitelabel/remove-branding/components/branding-settings-card.tsx @@ -3,12 +3,12 @@ import { EditBranding } from "@/modules/ee/whitelabel/remove-branding/components import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt"; import { getTranslate } from "@/tolgee/server"; +import { Project } from "@prisma/client"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; -import { TProject } from "@formbricks/types/project"; interface BrandingSettingsCardProps { canRemoveBranding: boolean; - project: TProject; + project: Project; environmentId: string; isReadOnly: boolean; } diff --git a/apps/web/modules/organization/settings/teams/page.tsx b/apps/web/modules/organization/settings/teams/page.tsx index dcf78db780..7ca9c69c58 100644 --- a/apps/web/modules/organization/settings/teams/page.tsx +++ b/apps/web/modules/organization/settings/teams/page.tsx @@ -24,7 +24,7 @@ export const TeamsPage = async (props) => { throw new Error(t("common.organization_not_found")); } - const canDoRoleManagement = await getRoleManagementPermission(organization); + const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); return ( diff --git a/apps/web/modules/projects/settings/(setup)/app-connection/page.tsx b/apps/web/modules/projects/settings/(setup)/app-connection/page.tsx index 0ec6a16116..037d7dbfca 100644 --- a/apps/web/modules/projects/settings/(setup)/app-connection/page.tsx +++ b/apps/web/modules/projects/settings/(setup)/app-connection/page.tsx @@ -31,8 +31,8 @@ export const AppConnectionPage = async (props) => { throw new Error(t("common.organization_not_found")); } - const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); - const canDoRoleManagement = await getRoleManagementPermission(organization); + const isMultiLanguageAllowed = await getMultiLanguagePermission(organization.billing.plan); + const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); return ( diff --git a/apps/web/modules/projects/settings/actions.ts b/apps/web/modules/projects/settings/actions.ts index e0215b4554..d8de2e775b 100644 --- a/apps/web/modules/projects/settings/actions.ts +++ b/apps/web/modules/projects/settings/actions.ts @@ -49,7 +49,7 @@ export const updateProjectAction = authenticatedActionClient throw new Error("Organization not found"); } - const canRemoveBranding = await getRemoveBrandingPermission(organization); + const canRemoveBranding = await getRemoveBrandingPermission(organization.billing.plan); if (parsedInput.data.inAppSurveyBranding !== undefined) { if (!canRemoveBranding) { diff --git a/apps/web/modules/projects/settings/api-keys/page.tsx b/apps/web/modules/projects/settings/api-keys/page.tsx index 0e749bbc65..39f94366ca 100644 --- a/apps/web/modules/projects/settings/api-keys/page.tsx +++ b/apps/web/modules/projects/settings/api-keys/page.tsx @@ -53,8 +53,8 @@ export const APIKeysPage = async (props) => { const isReadOnly = isMember && !hasManageAccess; - const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); - const canDoRoleManagement = await getRoleManagementPermission(organization); + const isMultiLanguageAllowed = await getMultiLanguagePermission(organization.billing.plan); + const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); return ( diff --git a/apps/web/modules/projects/settings/general/page.tsx b/apps/web/modules/projects/settings/general/page.tsx index 9159c2caca..8f765a9505 100644 --- a/apps/web/modules/projects/settings/general/page.tsx +++ b/apps/web/modules/projects/settings/general/page.tsx @@ -51,8 +51,8 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment const isReadOnly = isMember && !hasManageAccess; - const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); - const canDoRoleManagement = await getRoleManagementPermission(organization); + const isMultiLanguageAllowed = await getMultiLanguagePermission(organization.billing.plan); + const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); const isOwnerOrManager = isOwner || isManager; diff --git a/apps/web/modules/projects/settings/look/components/edit-logo.tsx b/apps/web/modules/projects/settings/look/components/edit-logo.tsx index 5bc80412cf..6a70fce3c9 100644 --- a/apps/web/modules/projects/settings/look/components/edit-logo.tsx +++ b/apps/web/modules/projects/settings/look/components/edit-logo.tsx @@ -10,14 +10,14 @@ import { ColorPicker } from "@/modules/ui/components/color-picker"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { FileInput } from "@/modules/ui/components/file-input"; import { Input } from "@/modules/ui/components/input"; +import { Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import Image from "next/image"; import { ChangeEvent, useRef, useState } from "react"; import toast from "react-hot-toast"; -import { TProject, TProjectUpdateInput } from "@formbricks/types/project"; interface EditLogoProps { - project: TProject; + project: Project; environmentId: string; isReadOnly: boolean; } @@ -62,7 +62,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps) setIsLoading(true); try { - const updatedProject: TProjectUpdateInput = { + const updatedProject: Project["logo"] = { logo: { url: logoUrl, bgColor: isBgColorEnabled ? logoBgColor : undefined }, }; const updateProjectResponse = await updateProjectAction({ @@ -92,7 +92,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps) setIsLoading(true); try { - const updatedProject: TProjectUpdateInput = { + const updatedProject: Project["logo"] = { logo: { url: undefined, bgColor: undefined }, }; const updateProjectResponse = await updateProjectAction({ diff --git a/apps/web/modules/projects/settings/look/components/edit-placement-form.tsx b/apps/web/modules/projects/settings/look/components/edit-placement-form.tsx index 217918a834..d489fb53e7 100644 --- a/apps/web/modules/projects/settings/look/components/edit-placement-form.tsx +++ b/apps/web/modules/projects/settings/look/components/edit-placement-form.tsx @@ -9,12 +9,12 @@ import { Label } from "@/modules/ui/components/label"; import { getPlacementStyle } from "@/modules/ui/components/preview-survey/lib/utils"; import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; import { cn } from "@formbricks/lib/cn"; -import { TProject } from "@formbricks/types/project"; const placements = [ { name: "common.bottom_right", value: "bottomRight", disabled: false }, @@ -25,7 +25,7 @@ const placements = [ ]; interface EditPlacementProps { - project: TProject; + project: Project; environmentId: string; isReadOnly: boolean; } diff --git a/apps/web/modules/projects/settings/look/components/theme-styling.tsx b/apps/web/modules/projects/settings/look/components/theme-styling.tsx index 9539a907d9..7b07225cd0 100644 --- a/apps/web/modules/projects/settings/look/components/theme-styling.tsx +++ b/apps/web/modules/projects/settings/look/components/theme-styling.tsx @@ -1,15 +1,14 @@ "use client"; -import { BackgroundStylingCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/BackgroundStylingCard"; -import { CardStylingSettings } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CardStylingSettings"; -import { FormStylingSettings } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FormStylingSettings"; import { previewSurvey } from "@/app/lib/templates"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { updateProjectAction } from "@/modules/projects/settings/actions"; -import { ThemeStylingPreviewSurvey } from "@/modules/projects/settings/look/components/theme-styling-preview-survey"; +import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings"; import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { AlertDialog } from "@/modules/ui/components/alert-dialog"; +import { BackgroundStylingCard } from "@/modules/ui/components/background-styling-card"; import { Button } from "@/modules/ui/components/button"; +import { CardStylingSettings } from "@/modules/ui/components/card-styling-settings"; import { FormControl, FormDescription, @@ -19,7 +18,9 @@ import { FormProvider, } from "@/modules/ui/components/form"; import { Switch } from "@/modules/ui/components/switch"; +import { ThemeStylingPreviewSurvey } from "@/modules/ui/components/theme-styling-preview-survey"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { RotateCcwIcon } from "lucide-react"; import { useRouter } from "next/navigation"; @@ -27,11 +28,11 @@ import { useCallback, useState } from "react"; import { SubmitHandler, UseFormReturn, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { defaultStyling } from "@formbricks/lib/styling/constants"; -import { TProject, TProjectStyling, ZProjectStyling } from "@formbricks/types/project"; +import { TProjectStyling, ZProjectStyling } from "@formbricks/types/project"; import { TSurvey, TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types"; interface ThemeStylingProps { - project: TProject; + project: Project; environmentId: string; colors: string[]; isUnsplashConfigured: boolean; diff --git a/apps/web/modules/projects/settings/look/lib/project.ts b/apps/web/modules/projects/settings/look/lib/project.ts new file mode 100644 index 0000000000..7384edfe56 --- /dev/null +++ b/apps/web/modules/projects/settings/look/lib/project.ts @@ -0,0 +1,43 @@ +import { Prisma, Project } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { z } from "zod"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { projectCache } from "@formbricks/lib/project/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { DatabaseError } from "@formbricks/types/errors"; + +export const getProjectByEnvironmentId = reactCache( + async (environmentId: string): Promise => + cache( + async () => { + validateInputs([environmentId, z.string().cuid2()]); + + let projectPrisma; + + try { + projectPrisma = await prisma.project.findFirst({ + where: { + environments: { + some: { + id: environmentId, + }, + }, + }, + }); + + return projectPrisma; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } + }, + [`project-settings-look-getProjectByEnvironmentId-${environmentId}`], + { + tags: [projectCache.tag.byEnvironmentId(environmentId)], + } + )() +); diff --git a/apps/web/modules/projects/settings/look/page.tsx b/apps/web/modules/projects/settings/look/page.tsx index 036dce8703..723f0c2e5b 100644 --- a/apps/web/modules/projects/settings/look/page.tsx +++ b/apps/web/modules/projects/settings/look/page.tsx @@ -10,6 +10,7 @@ import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { BrandingSettingsCard } from "@/modules/ee/whitelabel/remove-branding/components/branding-settings-card"; import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation"; import { EditLogo } from "@/modules/projects/settings/look/components/edit-logo"; +import { getProjectByEnvironmentId } from "@/modules/projects/settings/look/lib/project"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getTranslate } from "@/tolgee/server"; @@ -19,7 +20,6 @@ import { SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@formbricks/lib/constants import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; import { EditPlacementForm } from "./components/edit-placement-form"; import { ThemeStyling } from "./components/theme-styling"; @@ -41,7 +41,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ if (!organization) { throw new Error(t("common.organization_not_found")); } - const canRemoveBranding = await getWhiteLabelPermission(organization); + const canRemoveBranding = await getWhiteLabelPermission(organization.billing.plan); const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); const { isMember } = getAccessFlags(currentUserMembership?.role); @@ -51,8 +51,8 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ const isReadOnly = isMember && !hasManageAccess; - const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); - const canDoRoleManagement = await getRoleManagementPermission(organization); + const isMultiLanguageAllowed = await getMultiLanguagePermission(organization.billing.plan); + const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); return ( diff --git a/apps/web/modules/projects/settings/tags/page.tsx b/apps/web/modules/projects/settings/tags/page.tsx index 80d9150ea7..6dad92e541 100644 --- a/apps/web/modules/projects/settings/tags/page.tsx +++ b/apps/web/modules/projects/settings/tags/page.tsx @@ -59,8 +59,8 @@ export const TagsPage = async (props) => { const isReadOnly = isMember && !hasManageAccess; - const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); - const canDoRoleManagement = await getRoleManagementPermission(organization); + const isMultiLanguageAllowed = await getMultiLanguagePermission(organization.billing.plan); + const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); return ( diff --git a/apps/web/modules/survey-follow-ups/lib/utils.ts b/apps/web/modules/survey-follow-ups/lib/utils.ts deleted file mode 100644 index e316f827ce..0000000000 --- a/apps/web/modules/survey-follow-ups/lib/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IS_FORMBRICKS_CLOUD, PROJECT_FEATURE_KEYS } from "@formbricks/lib/constants"; -import { TOrganization } from "@formbricks/types/organizations"; - -export const getSurveyFollowUpsPermission = async (organization: TOrganization): Promise => { - if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PROJECT_FEATURE_KEYS.FREE; - return true; -}; diff --git a/apps/web/modules/surveys/components/QuestionFormInput/components/FallbackInput.tsx b/apps/web/modules/survey/components/question-form-input/components/fallback-input.tsx similarity index 100% rename from apps/web/modules/surveys/components/QuestionFormInput/components/FallbackInput.tsx rename to apps/web/modules/survey/components/question-form-input/components/fallback-input.tsx diff --git a/apps/web/modules/surveys/components/QuestionFormInput/components/MultiLangWrapper.tsx b/apps/web/modules/survey/components/question-form-input/components/multi-lang-wrapper.tsx similarity index 100% rename from apps/web/modules/surveys/components/QuestionFormInput/components/MultiLangWrapper.tsx rename to apps/web/modules/survey/components/question-form-input/components/multi-lang-wrapper.tsx diff --git a/apps/web/modules/surveys/components/QuestionFormInput/components/RecallItemSelect.tsx b/apps/web/modules/survey/components/question-form-input/components/recall-item-select.tsx similarity index 100% rename from apps/web/modules/surveys/components/QuestionFormInput/components/RecallItemSelect.tsx rename to apps/web/modules/survey/components/question-form-input/components/recall-item-select.tsx diff --git a/apps/web/modules/surveys/components/QuestionFormInput/components/RecallWrapper.tsx b/apps/web/modules/survey/components/question-form-input/components/recall-wrapper.tsx similarity index 97% rename from apps/web/modules/surveys/components/QuestionFormInput/components/RecallWrapper.tsx rename to apps/web/modules/survey/components/question-form-input/components/recall-wrapper.tsx index 33b087d680..b95a54f98d 100644 --- a/apps/web/modules/surveys/components/QuestionFormInput/components/RecallWrapper.tsx +++ b/apps/web/modules/survey/components/question-form-input/components/recall-wrapper.tsx @@ -1,7 +1,7 @@ "use client"; -import { FallbackInput } from "@/modules/surveys/components/QuestionFormInput/components/FallbackInput"; -import { RecallItemSelect } from "@/modules/surveys/components/QuestionFormInput/components/RecallItemSelect"; +import { FallbackInput } from "@/modules/survey/components/question-form-input/components/fallback-input"; +import { RecallItemSelect } from "@/modules/survey/components/question-form-input/components/recall-item-select"; import { Button } from "@/modules/ui/components/button"; import { useTranslate } from "@tolgee/react"; import { PencilIcon } from "lucide-react"; diff --git a/apps/web/modules/surveys/components/QuestionFormInput/index.tsx b/apps/web/modules/survey/components/question-form-input/index.tsx similarity index 98% rename from apps/web/modules/surveys/components/QuestionFormInput/index.tsx rename to apps/web/modules/survey/components/question-form-input/index.tsx index c9a77dfba6..da896f4082 100644 --- a/apps/web/modules/surveys/components/QuestionFormInput/index.tsx +++ b/apps/web/modules/survey/components/question-form-input/index.tsx @@ -1,7 +1,7 @@ "use client"; -import { MultiLangWrapper } from "@/modules/surveys/components/QuestionFormInput/components/MultiLangWrapper"; -import { RecallWrapper } from "@/modules/surveys/components/QuestionFormInput/components/RecallWrapper"; +import { MultiLangWrapper } from "@/modules/survey/components/question-form-input/components/multi-lang-wrapper"; +import { RecallWrapper } from "@/modules/survey/components/question-form-input/components/recall-wrapper"; import { Button } from "@/modules/ui/components/button"; import { FileInput } from "@/modules/ui/components/file-input"; import { Input } from "@/modules/ui/components/input"; diff --git a/apps/web/modules/surveys/components/QuestionFormInput/utils.ts b/apps/web/modules/survey/components/question-form-input/utils.ts similarity index 100% rename from apps/web/modules/surveys/components/QuestionFormInput/utils.ts rename to apps/web/modules/survey/components/question-form-input/utils.ts diff --git a/apps/web/modules/surveys/components/TemplateList/actions.ts b/apps/web/modules/survey/components/template-list/actions.ts similarity index 85% rename from apps/web/modules/surveys/components/TemplateList/actions.ts rename to apps/web/modules/survey/components/template-list/actions.ts index 351ea64171..dfac66b05b 100644 --- a/apps/web/modules/surveys/components/TemplateList/actions.ts +++ b/apps/web/modules/survey/components/template-list/actions.ts @@ -4,16 +4,15 @@ import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper"; import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions"; -import { getSurveyFollowUpsPermission } from "@/modules/survey-follow-ups/lib/utils"; +import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; +import { createSurvey } from "@/modules/survey/components/template-list/lib/survey"; +import { getOrganizationBilling } from "@/modules/survey/lib/survey"; import { z } from "zod"; -import { getOrganization } from "@formbricks/lib/organization/service"; -import { createSurvey } from "@formbricks/lib/survey/service"; -import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ZSurveyCreateInput } from "@formbricks/types/surveys/types"; const ZCreateSurveyAction = z.object({ - environmentId: ZId, + environmentId: z.string().cuid2(), surveyBody: ZSurveyCreateInput, }); @@ -26,12 +25,12 @@ const ZCreateSurveyAction = z.object({ * @throws { OperationNotAllowedError } If survey follow-ups are not enabled for the organization. */ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise => { - const organization = await getOrganization(organizationId); - if (!organization) { + const organizationBilling = await getOrganizationBilling(organizationId); + if (!organizationBilling) { throw new ResourceNotFoundError("Organization not found", organizationId); } - const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization); + const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationBilling.plan); if (!isSurveyFollowUpsEnabled) { throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization"); } diff --git a/apps/web/modules/surveys/components/TemplateList/components/StartFromScratchTemplate.tsx b/apps/web/modules/survey/components/template-list/components/start-from-scratch-template.tsx similarity index 96% rename from apps/web/modules/surveys/components/TemplateList/components/StartFromScratchTemplate.tsx rename to apps/web/modules/survey/components/template-list/components/start-from-scratch-template.tsx index febe890a44..7f07d695a1 100644 --- a/apps/web/modules/surveys/components/TemplateList/components/StartFromScratchTemplate.tsx +++ b/apps/web/modules/survey/components/template-list/components/start-from-scratch-template.tsx @@ -2,10 +2,10 @@ import { customSurveyTemplate } from "@/app/lib/templates"; import { Button } from "@/modules/ui/components/button"; +import { Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { PlusCircleIcon } from "lucide-react"; import { cn } from "@formbricks/lib/cn"; -import { TProject } from "@formbricks/types/project"; import { TTemplate } from "@formbricks/types/templates"; import { replacePresetPlaceholders } from "../lib/utils"; @@ -13,7 +13,7 @@ interface StartFromScratchTemplateProps { activeTemplate: TTemplate | null; setActiveTemplate: (template: TTemplate) => void; onTemplateClick: (template: TTemplate) => void; - project: TProject; + project: Project; createSurvey: (template: TTemplate) => void; loading: boolean; noPreview?: boolean; diff --git a/apps/web/modules/surveys/components/TemplateList/components/TemplateFilters.tsx b/apps/web/modules/survey/components/template-list/components/template-filters.tsx similarity index 100% rename from apps/web/modules/surveys/components/TemplateList/components/TemplateFilters.tsx rename to apps/web/modules/survey/components/template-list/components/template-filters.tsx diff --git a/apps/web/modules/surveys/components/TemplateList/components/TemplateTags.tsx b/apps/web/modules/survey/components/template-list/components/template-tags.tsx similarity index 100% rename from apps/web/modules/surveys/components/TemplateList/components/TemplateTags.tsx rename to apps/web/modules/survey/components/template-list/components/template-tags.tsx diff --git a/apps/web/modules/surveys/components/TemplateList/components/Template.tsx b/apps/web/modules/survey/components/template-list/components/template.tsx similarity index 94% rename from apps/web/modules/surveys/components/TemplateList/components/Template.tsx rename to apps/web/modules/survey/components/template-list/components/template.tsx index 3ca60693d5..0064bde376 100644 --- a/apps/web/modules/surveys/components/TemplateList/components/Template.tsx +++ b/apps/web/modules/survey/components/template-list/components/template.tsx @@ -1,19 +1,19 @@ "use client"; import { Button } from "@/modules/ui/components/button"; +import { Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { cn } from "@formbricks/lib/cn"; -import { TProject } from "@formbricks/types/project"; import { TTemplate, TTemplateFilter } from "@formbricks/types/templates"; import { replacePresetPlaceholders } from "../lib/utils"; -import { TemplateTags } from "./TemplateTags"; +import { TemplateTags } from "./template-tags"; interface TemplateProps { template: TTemplate; activeTemplate: TTemplate | null; setActiveTemplate: (template: TTemplate) => void; onTemplateClick?: (template: TTemplate) => void; - project: TProject; + project: Project; createSurvey: (template: TTemplate) => void; loading: boolean; selectedFilter: TTemplateFilter[]; diff --git a/apps/web/modules/surveys/components/TemplateList/index.tsx b/apps/web/modules/survey/components/template-list/index.tsx similarity index 87% rename from apps/web/modules/surveys/components/TemplateList/index.tsx rename to apps/web/modules/survey/components/template-list/index.tsx index a60f52b74c..4cfcc63c9d 100644 --- a/apps/web/modules/surveys/components/TemplateList/index.tsx +++ b/apps/web/modules/survey/components/template-list/index.tsx @@ -2,24 +2,23 @@ import { templates } from "@/app/lib/templates"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; import toast from "react-hot-toast"; -import type { TEnvironment } from "@formbricks/types/environment"; -import { type TProject, ZProjectConfigChannel, ZProjectConfigIndustry } from "@formbricks/types/project"; +import { ZProjectConfigChannel, ZProjectConfigIndustry } from "@formbricks/types/project"; import { TSurveyCreateInput, TSurveyType } from "@formbricks/types/surveys/types"; import { TTemplate, TTemplateFilter, ZTemplateRole } from "@formbricks/types/templates"; -import { TUser } from "@formbricks/types/user"; import { createSurveyAction } from "./actions"; -import { StartFromScratchTemplate } from "./components/StartFromScratchTemplate"; -import { Template } from "./components/Template"; -import { TemplateFilters } from "./components/TemplateFilters"; +import { StartFromScratchTemplate } from "./components/start-from-scratch-template"; +import { Template } from "./components/template"; +import { TemplateFilters } from "./components/template-filters"; interface TemplateListProps { - user: TUser; - environment: TEnvironment; - project: TProject; + userId: string; + environmentId: string; + project: Project; templateSearch?: string; showFilters?: boolean; prefilledFilters: TTemplateFilter[]; @@ -28,9 +27,9 @@ interface TemplateListProps { } export const TemplateList = ({ - user, + userId, project, - environment, + environmentId, showFilters = true, templateSearch, prefilledFilters, @@ -59,15 +58,15 @@ export const TemplateList = ({ const augmentedTemplate: TSurveyCreateInput = { ...activeTemplate.preset, type: surveyType, - createdBy: user.id, + createdBy: userId, }; const createSurveyResponse = await createSurveyAction({ - environmentId: environment.id, + environmentId: environmentId, surveyBody: augmentedTemplate, }); if (createSurveyResponse?.data) { - router.push(`/environments/${environment.id}/surveys/${createSurveyResponse.data.id}/edit`); + router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit`); } else { const errorMessage = getFormattedErrorMessage(createSurveyResponse); toast.error(errorMessage); diff --git a/apps/web/modules/survey/components/template-list/lib/organization.ts b/apps/web/modules/survey/components/template-list/lib/organization.ts new file mode 100644 index 0000000000..452a30f3ab --- /dev/null +++ b/apps/web/modules/survey/components/template-list/lib/organization.ts @@ -0,0 +1,35 @@ +import { updateUser } from "@/modules/survey/components/template-list/lib/user"; +import { prisma } from "@formbricks/database"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { TUserNotificationSettings } from "@formbricks/types/user"; + +export const subscribeOrganizationMembersToSurveyResponses = async ( + surveyId: string, + createdBy: string +): Promise => { + try { + const surveyCreator = await prisma.user.findUnique({ + where: { + id: createdBy, + }, + }); + + if (!surveyCreator) { + throw new ResourceNotFoundError("User", createdBy); + } + + const defaultSettings = { alert: {}, weeklySummary: {} }; + const updatedNotificationSettings: TUserNotificationSettings = { + ...defaultSettings, + ...surveyCreator.notificationSettings, + }; + + updatedNotificationSettings.alert[surveyId] = true; + + await updateUser(surveyCreator.id, { + notificationSettings: updatedNotificationSettings, + }); + } catch (error) { + throw error; + } +}; diff --git a/apps/web/modules/survey/components/template-list/lib/survey.ts b/apps/web/modules/survey/components/template-list/lib/survey.ts new file mode 100644 index 0000000000..cd40045c90 --- /dev/null +++ b/apps/web/modules/survey/components/template-list/lib/survey.ts @@ -0,0 +1,185 @@ +import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils"; +import { subscribeOrganizationMembersToSurveyResponses } from "@/modules/survey/components/template-list/lib/organization"; +import { handleTriggerUpdates } from "@/modules/survey/components/template-list/lib/utils"; +import { getActionClasses } from "@/modules/survey/lib/action-class"; +import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization"; +import { selectSurvey } from "@/modules/survey/lib/survey"; +import { getInsightsEnabled } from "@/modules/survey/lib/utils"; +import { doesSurveyHasOpenTextQuestion } from "@/modules/survey/lib/utils"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@formbricks/database"; +import { segmentCache } from "@formbricks/lib/cache/segment"; +import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer"; +import { surveyCache } from "@formbricks/lib/survey/cache"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TSurveyCreateInput } from "@formbricks/types/surveys/types"; + +export const createSurvey = async ( + environmentId: string, + surveyBody: TSurveyCreateInput +): Promise => { + try { + const { createdBy, ...restSurveyBody } = surveyBody; + + // empty languages array + if (!restSurveyBody.languages?.length) { + delete restSurveyBody.languages; + } + + const actionClasses = await getActionClasses(environmentId); + + // @ts-expect-error + let data: Omit = { + ...restSurveyBody, + // TODO: Create with attributeFilters + triggers: restSurveyBody.triggers + ? handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses) + : undefined, + attributeFilters: undefined, + }; + + if (createdBy) { + data.creator = { + connect: { + id: createdBy, + }, + }; + } + + const organizationId = await getOrganizationIdFromEnvironmentId(environmentId); + const organization = await getOrganizationAIKeys(organizationId); + if (!organization) { + throw new ResourceNotFoundError("Organization", null); + } + + //AI Insights + const isAIEnabled = await getIsAIEnabled(organization); + if (isAIEnabled) { + if (doesSurveyHasOpenTextQuestion(data.questions ?? [])) { + const openTextQuestions = data.questions?.filter((question) => question.type === "openText") ?? []; + const insightsEnabledValues = await Promise.all( + openTextQuestions.map(async (question) => { + const insightsEnabled = await getInsightsEnabled(question); + + return { id: question.id, insightsEnabled }; + }) + ); + + data.questions = data.questions?.map((question) => { + const index = insightsEnabledValues.findIndex((item) => item.id === question.id); + if (index !== -1) { + return { + ...question, + insightsEnabled: insightsEnabledValues[index].insightsEnabled, + }; + } + + return question; + }); + } + } + + // Survey follow-ups + if (restSurveyBody.followUps?.length) { + data.followUps = { + create: restSurveyBody.followUps.map((followUp) => ({ + name: followUp.name, + trigger: followUp.trigger, + action: followUp.action, + })), + }; + } else { + delete data.followUps; + } + + const survey = await prisma.survey.create({ + data: { + ...data, + environment: { + connect: { + id: environmentId, + }, + }, + }, + select: selectSurvey, + }); + + // if the survey created is an "app" survey, we also create a private segment for it. + if (survey.type === "app") { + // const newSegment = await createSegment({ + // environmentId: parsedEnvironmentId, + // surveyId: survey.id, + // filters: [], + // title: survey.id, + // isPrivate: true, + // }); + + const newSegment = await prisma.segment.create({ + data: { + title: survey.id, + filters: [], + isPrivate: true, + environment: { + connect: { + id: environmentId, + }, + }, + }, + }); + + await prisma.survey.update({ + where: { + id: survey.id, + }, + data: { + segment: { + connect: { + id: newSegment.id, + }, + }, + }, + }); + + segmentCache.revalidate({ + id: newSegment.id, + environmentId: survey.environmentId, + }); + } + + // TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration + // @ts-expect-error + const transformedSurvey: TSurvey = { + ...survey, + ...(survey.segment && { + segment: { + ...survey.segment, + surveys: survey.segment.surveys.map((survey) => survey.id), + }, + }), + }; + + surveyCache.revalidate({ + id: survey.id, + environmentId: survey.environmentId, + resultShareKey: survey.resultShareKey ?? undefined, + }); + + if (createdBy) { + await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy); + } + + await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", { + surveyId: survey.id, + surveyType: survey.type, + }); + + return transformedSurvey; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } +}; diff --git a/apps/web/modules/survey/components/template-list/lib/user.ts b/apps/web/modules/survey/components/template-list/lib/user.ts new file mode 100644 index 0000000000..6cf63a4138 --- /dev/null +++ b/apps/web/modules/survey/components/template-list/lib/user.ts @@ -0,0 +1,45 @@ +import { Prisma } from "@prisma/client"; +import { prisma } from "@formbricks/database"; +import { userCache } from "@formbricks/lib/user/cache"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { TUser } from "@formbricks/types/user"; +import { TUserUpdateInput } from "@formbricks/types/user"; + +// function to update a user's user +export const updateUser = async (personId: string, data: TUserUpdateInput): Promise => { + try { + const updatedUser = await prisma.user.update({ + where: { + id: personId, + }, + data: data, + select: { + id: true, + name: true, + email: true, + emailVerified: true, + imageUrl: true, + createdAt: true, + updatedAt: true, + role: true, + twoFactorEnabled: true, + identityProvider: true, + objective: true, + notificationSettings: true, + locale: true, + }, + }); + + userCache.revalidate({ + email: updatedUser.email, + id: updatedUser.id, + }); + + return updatedUser; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") { + throw new ResourceNotFoundError("User", personId); + } + throw error; // Re-throw any other errors + } +}; diff --git a/apps/web/modules/survey/components/template-list/lib/utils.ts b/apps/web/modules/survey/components/template-list/lib/utils.ts new file mode 100644 index 0000000000..4f19a09b09 --- /dev/null +++ b/apps/web/modules/survey/components/template-list/lib/utils.ts @@ -0,0 +1,128 @@ +import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger"; +import { ActionClass } from "@prisma/client"; +import { TFnType } from "@tolgee/react"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; +import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone"; +import { surveyCache } from "@formbricks/lib/survey/cache"; +import { InvalidInputError } from "@formbricks/types/errors"; +import { TProject, TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project"; +import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types"; +import { TTemplate, TTemplateRole } from "@formbricks/types/templates"; + +export const replaceQuestionPresetPlaceholders = ( + question: TSurveyQuestion, + project: TProject +): TSurveyQuestion => { + if (!project) return question; + const newQuestion = structuredClone(question); + const defaultLanguageCode = "default"; + if (newQuestion.headline) { + newQuestion.headline[defaultLanguageCode] = getLocalizedValue( + newQuestion.headline, + defaultLanguageCode + ).replace("$[projectName]", project.name); + } + if (newQuestion.subheader) { + newQuestion.subheader[defaultLanguageCode] = getLocalizedValue( + newQuestion.subheader, + defaultLanguageCode + )?.replace("$[projectName]", project.name); + } + return newQuestion; +}; + +// replace all occurences of projectName with the actual project name in the current template +export const replacePresetPlaceholders = (template: TTemplate, project: any) => { + const preset = structuredClone(template.preset); + preset.name = preset.name.replace("$[projectName]", project.name); + preset.questions = preset.questions.map((question) => { + return replaceQuestionPresetPlaceholders(question, project); + }); + return { ...template, preset }; +}; + +export const getChannelMapping = (t: TFnType): { value: TProjectConfigChannel; label: string }[] => [ + { value: "website", label: t("common.website_survey") }, + { value: "app", label: t("common.app_survey") }, + { value: "link", label: t("common.link_survey") }, +]; + +export const getIndustryMapping = (t: TFnType): { value: TProjectConfigIndustry; label: string }[] => [ + { value: "eCommerce", label: t("common.e_commerce") }, + { value: "saas", label: t("common.saas") }, + { value: "other", label: t("common.other") }, +]; + +export const getRoleMapping = (t: TFnType): { value: TTemplateRole; label: string }[] => [ + { value: "productManager", label: t("common.product_manager") }, + { value: "customerSuccess", label: t("common.customer_success") }, + { value: "marketing", label: t("common.marketing") }, + { value: "sales", label: t("common.sales") }, + { value: "peopleManager", label: t("common.people_manager") }, +]; + +const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => { + if (!triggers) return; + + // check if all the triggers are valid + triggers.forEach((trigger) => { + if (!actionClasses.find((actionClass) => actionClass.id === trigger.actionClass.id)) { + throw new InvalidInputError("Invalid trigger id"); + } + }); + + // check if all the triggers are unique + const triggerIds = triggers.map((trigger) => trigger.actionClass.id); + + if (new Set(triggerIds).size !== triggerIds.length) { + throw new InvalidInputError("Duplicate trigger id"); + } +}; + +export const handleTriggerUpdates = ( + updatedTriggers: TSurvey["triggers"], + currentTriggers: TSurvey["triggers"], + actionClasses: ActionClass[] +) => { + if (!updatedTriggers) return {}; + checkTriggersValidity(updatedTriggers, actionClasses); + + const currentTriggerIds = currentTriggers.map((trigger) => trigger.actionClass.id); + const updatedTriggerIds = updatedTriggers.map((trigger) => trigger.actionClass.id); + + // added triggers are triggers that are not in the current triggers and are there in the new triggers + const addedTriggers = updatedTriggers.filter( + (trigger) => !currentTriggerIds.includes(trigger.actionClass.id) + ); + + // deleted triggers are triggers that are not in the new triggers and are there in the current triggers + const deletedTriggers = currentTriggers.filter( + (trigger) => !updatedTriggerIds.includes(trigger.actionClass.id) + ); + + // Construct the triggers update object + const triggersUpdate: TriggerUpdate = {}; + + if (addedTriggers.length > 0) { + triggersUpdate.create = addedTriggers.map((trigger) => ({ + actionClassId: trigger.actionClass.id, + })); + } + + if (deletedTriggers.length > 0) { + // disconnect the public triggers from the survey + triggersUpdate.deleteMany = { + actionClassId: { + in: deletedTriggers.map((trigger) => trigger.actionClass.id), + }, + }; + } + + [...addedTriggers, ...deletedTriggers].forEach((trigger) => { + surveyCache.revalidate({ + actionClassId: trigger.actionClass.id, + }); + }); + + return triggersUpdate; +}; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts b/apps/web/modules/survey/editor/actions.ts similarity index 92% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts rename to apps/web/modules/survey/editor/actions.ts index aa6db2a9ff..0f7c098222 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts +++ b/apps/web/modules/survey/editor/actions.ts @@ -10,17 +10,16 @@ import { getProjectIdFromSurveyId, } from "@/lib/utils/helper"; import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions"; -import { getSurveyFollowUpsPermission } from "@/modules/survey-follow-ups/lib/utils"; +import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; +import { updateSurvey } from "@/modules/survey/editor/lib/survey"; +import { getOrganizationBilling } from "@/modules/survey/lib/survey"; import { z } from "zod"; -import { createActionClass } from "@formbricks/lib/actionClass/service"; import { UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@formbricks/lib/constants"; -import { getOrganization } from "@formbricks/lib/organization/service"; -import { getProject } from "@formbricks/lib/project/service"; -import { updateSurvey } from "@formbricks/lib/survey/service"; import { ZActionClassInput } from "@formbricks/types/action-classes"; -import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ZSurvey } from "@formbricks/types/surveys/types"; +import { getProject } from "./lib/project"; +import { createActionClass } from "@/modules/survey/editor/lib/action-class"; /** * Checks if survey follow-ups are enabled for the given organization. @@ -31,12 +30,12 @@ import { ZSurvey } from "@formbricks/types/surveys/types"; * @throws { OperationNotAllowedError } If survey follow-ups are not enabled for the organization. */ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise => { - const organization = await getOrganization(organizationId); - if (!organization) { + const organizationBilling = await getOrganizationBilling(organizationId); + if (!organizationBilling) { throw new ResourceNotFoundError("Organization", organizationId); } - const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization); + const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationBilling.plan); if (!isSurveyFollowUpsEnabled) { throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization"); } @@ -74,7 +73,7 @@ export const updateSurveyAction = authenticatedActionClient }); const ZRefetchProjectAction = z.object({ - projectId: ZId, + projectId: z.string().cuid2(), }); export const refetchProjectAction = authenticatedActionClient diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddActionModal.tsx b/apps/web/modules/survey/editor/components/add-action-modal.tsx similarity index 83% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddActionModal.tsx rename to apps/web/modules/survey/editor/components/add-action-modal.tsx index 480bd0141a..a01c9af00e 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddActionModal.tsx +++ b/apps/web/modules/survey/editor/components/add-action-modal.tsx @@ -1,18 +1,18 @@ "use client"; +import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab"; +import { SavedActionsTab } from "@/modules/survey/editor/components/saved-actions-tab"; import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs"; +import { ActionClass } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; -import { TActionClass } from "@formbricks/types/action-classes"; import { TSurvey } from "@formbricks/types/surveys/types"; -import { CreateNewActionTab } from "./CreateNewActionTab"; -import { SavedActionsTab } from "./SavedActionsTab"; interface AddActionModalProps { open: boolean; setOpen: React.Dispatch>; environmentId: string; - actionClasses: TActionClass[]; - setActionClasses: React.Dispatch>; + actionClasses: ActionClass[]; + setActionClasses: React.Dispatch>; isReadOnly: boolean; localSurvey: TSurvey; setLocalSurvey: React.Dispatch>; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton.tsx b/apps/web/modules/survey/editor/components/add-ending-card-button.tsx similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton.tsx rename to apps/web/modules/survey/editor/components/add-ending-card-button.tsx diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx b/apps/web/modules/survey/editor/components/add-question-button.tsx similarity index 97% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx rename to apps/web/modules/survey/editor/components/add-question-button.tsx index b5891cdf1f..f0903f035c 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx +++ b/apps/web/modules/survey/editor/components/add-question-button.tsx @@ -2,6 +2,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { createId } from "@paralleldrive/cuid2"; +import { Project } from "@prisma/client"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useTranslate } from "@tolgee/react"; import { PlusIcon } from "lucide-react"; @@ -13,11 +14,10 @@ import { getQuestionTypes, universalQuestionPresets, } from "@formbricks/lib/utils/questions"; -import { TProject } from "@formbricks/types/project"; interface AddQuestionButtonProps { addQuestion: (question: any) => void; - project: TProject; + project: Project; isCxMode: boolean; } diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddressQuestionForm.tsx b/apps/web/modules/survey/editor/components/address-question-form.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddressQuestionForm.tsx rename to apps/web/modules/survey/editor/components/address-question-form.tsx index 969cf9ddb7..770c954f5a 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddressQuestionForm.tsx +++ b/apps/web/modules/survey/editor/components/address-question-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { Button } from "@/modules/ui/components/button"; import { QuestionToggleTable } from "@/modules/ui/components/question-toggle-table"; import { useAutoAnimate } from "@formkit/auto-animate/react"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedSettings.tsx b/apps/web/modules/survey/editor/components/advanced-settings.tsx similarity index 80% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedSettings.tsx rename to apps/web/modules/survey/editor/components/advanced-settings.tsx index b6d465c2fa..0677305985 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedSettings.tsx +++ b/apps/web/modules/survey/editor/components/advanced-settings.tsx @@ -1,6 +1,6 @@ -import { ConditionalLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConditionalLogic"; +import { ConditionalLogic } from "@/modules/survey/editor/components/conditional-logic"; +import { UpdateQuestionId } from "@/modules/survey/editor/components/update-question-id"; import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types"; -import { UpdateQuestionId } from "./UpdateQuestionId"; interface AdvancedSettingsProps { question: TSurveyQuestion; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AnimatedSurveyBg.tsx b/apps/web/modules/survey/editor/components/animated-survey-bg.tsx similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AnimatedSurveyBg.tsx rename to apps/web/modules/survey/editor/components/animated-survey-bg.tsx diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx b/apps/web/modules/survey/editor/components/cal-question-form.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx rename to apps/web/modules/survey/editor/components/cal-question-form.tsx index 46975452e5..3ec9bd7fc3 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx +++ b/apps/web/modules/survey/editor/components/cal-question-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { Button } from "@/modules/ui/components/button"; import { Input } from "@/modules/ui/components/input"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ColorSurveyBg.tsx b/apps/web/modules/survey/editor/components/color-survey-bg.tsx similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ColorSurveyBg.tsx rename to apps/web/modules/survey/editor/components/color-survey-bg.tsx diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConditionalLogic.tsx b/apps/web/modules/survey/editor/components/conditional-logic.tsx similarity index 96% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConditionalLogic.tsx rename to apps/web/modules/survey/editor/components/conditional-logic.tsx index 0f5db1b810..4bdf366c25 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConditionalLogic.tsx +++ b/apps/web/modules/survey/editor/components/conditional-logic.tsx @@ -1,10 +1,10 @@ "use client"; -import { LogicEditor } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor"; +import { LogicEditor } from "@/modules/survey/editor/components/logic-editor"; import { getDefaultOperatorForQuestion, replaceEndingCardHeadlineRecall, -} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils"; +} from "@/modules/survey/editor/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { DropdownMenu, diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx b/apps/web/modules/survey/editor/components/consent-question-form.tsx similarity index 96% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx rename to apps/web/modules/survey/editor/components/consent-question-form.tsx index 956bada9d0..df4bd7987a 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx +++ b/apps/web/modules/survey/editor/components/consent-question-form.tsx @@ -1,7 +1,7 @@ "use client"; import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { Label } from "@/modules/ui/components/label"; import { useTranslate } from "@tolgee/react"; import { type JSX, useState } from "react"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ContactInfoQuestionForm.tsx b/apps/web/modules/survey/editor/components/contact-info-question-form.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ContactInfoQuestionForm.tsx rename to apps/web/modules/survey/editor/components/contact-info-question-form.tsx index ecd02dca46..f7e8951a5e 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ContactInfoQuestionForm.tsx +++ b/apps/web/modules/survey/editor/components/contact-info-question-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { Button } from "@/modules/ui/components/button"; import { QuestionToggleTable } from "@/modules/ui/components/question-toggle-table"; import { useAutoAnimate } from "@formkit/auto-animate/react"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab.tsx b/apps/web/modules/survey/editor/components/create-new-action-tab.tsx similarity index 97% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab.tsx rename to apps/web/modules/survey/editor/components/create-new-action-tab.tsx index 6f3346ab32..dbd7fea9a4 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab.tsx +++ b/apps/web/modules/survey/editor/components/create-new-action-tab.tsx @@ -9,13 +9,13 @@ import { Label } from "@/modules/ui/components/label"; import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form"; import { TabToggle } from "@/modules/ui/components/tab-toggle"; import { zodResolver } from "@hookform/resolvers/zod"; +import { ActionClass } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { useMemo } from "react"; import { FormProvider, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; import { - TActionClass, TActionClassInput, TActionClassInputCode, ZActionClassInput, @@ -24,8 +24,8 @@ import { TSurvey } from "@formbricks/types/surveys/types"; import { createActionClassAction } from "../actions"; interface CreateNewActionTabProps { - actionClasses: TActionClass[]; - setActionClasses: React.Dispatch>; + actionClasses: ActionClass[]; + setActionClasses: React.Dispatch>; isReadOnly: boolean; setLocalSurvey?: React.Dispatch>; setOpen: React.Dispatch>; @@ -148,7 +148,7 @@ export const CreateNewActionTab = ({ const newActionClass = createActionClassResposne.data; if (setActionClasses) { - setActionClasses((prevActionClasses: TActionClass[]) => [...prevActionClasses, newActionClass]); + setActionClasses((prevActionClasses: ActionClass[]) => [...prevActionClasses, newActionClass]); } if (setLocalSurvey) { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx b/apps/web/modules/survey/editor/components/cta-question-form.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx rename to apps/web/modules/survey/editor/components/cta-question-form.tsx index 750b068497..7b1a181d05 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx +++ b/apps/web/modules/survey/editor/components/cta-question-form.tsx @@ -1,7 +1,7 @@ "use client"; import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { Input } from "@/modules/ui/components/input"; import { Label } from "@/modules/ui/components/label"; import { OptionsSwitch } from "@/modules/ui/components/options-switch"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx b/apps/web/modules/survey/editor/components/date-question-form.tsx similarity index 97% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx rename to apps/web/modules/survey/editor/components/date-question-form.tsx index 95df179563..04ba6b1e50 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx +++ b/apps/web/modules/survey/editor/components/date-question-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { Button } from "@/modules/ui/components/button"; import { Label } from "@/modules/ui/components/label"; import { OptionsSwitch } from "@/modules/ui/components/options-switch"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditEndingCard.tsx b/apps/web/modules/survey/editor/components/edit-ending-card.tsx similarity index 94% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditEndingCard.tsx rename to apps/web/modules/survey/editor/components/edit-ending-card.tsx index cf97de7029..361665c302 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditEndingCard.tsx +++ b/apps/web/modules/survey/editor/components/edit-ending-card.tsx @@ -1,12 +1,9 @@ "use client"; -import { EditorCardMenu } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu"; -import { EndScreenForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm"; -import { RedirectUrlForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm"; -import { - findEndingCardUsedInLogic, - formatTextWithSlashes, -} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils"; +import { EditorCardMenu } from "@/modules/survey/editor/components/editor-card-menu"; +import { EndScreenForm } from "@/modules/survey/editor/components/end-screen-form"; +import { RedirectUrlForm } from "@/modules/survey/editor/components/redirect-url-form"; +import { findEndingCardUsedInLogic, formatTextWithSlashes } from "@/modules/survey/editor/lib/utils"; import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal"; import { OptionsSwitch } from "@/modules/ui/components/options-switch"; import { TooltipRenderer } from "@/modules/ui/components/tooltip"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx b/apps/web/modules/survey/editor/components/edit-welcome-card.tsx similarity index 99% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx rename to apps/web/modules/survey/editor/components/edit-welcome-card.tsx index 3939a520ab..ebfc08a8b1 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx +++ b/apps/web/modules/survey/editor/components/edit-welcome-card.tsx @@ -1,7 +1,7 @@ "use client"; import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { FileInput } from "@/modules/ui/components/file-input"; import { Label } from "@/modules/ui/components/label"; import { Switch } from "@/modules/ui/components/switch"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu.tsx b/apps/web/modules/survey/editor/components/editor-card-menu.tsx similarity index 99% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu.tsx rename to apps/web/modules/survey/editor/components/editor-card-menu.tsx index 4712819c96..0a0033d58a 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu.tsx +++ b/apps/web/modules/survey/editor/components/editor-card-menu.tsx @@ -13,6 +13,7 @@ import { } from "@/modules/ui/components/dropdown-menu"; import { TooltipRenderer } from "@/modules/ui/components/tooltip"; import { createId } from "@paralleldrive/cuid2"; +import { Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react"; import { useState } from "react"; @@ -22,7 +23,6 @@ import { getQuestionIconMap, getQuestionNameMap, } from "@formbricks/lib/utils/questions"; -import { TProject } from "@formbricks/types/project"; import { TSurvey, TSurveyEndScreenCard, @@ -42,7 +42,7 @@ interface EditorCardMenuProps { updateCard: (cardIdx: number, updatedAttributes: any) => void; addCard: (question: any, index?: number) => void; cardType: "question" | "ending"; - project?: TProject; + project?: Project; isCxMode?: boolean; } diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm.tsx b/apps/web/modules/survey/editor/components/end-screen-form.tsx similarity index 97% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm.tsx rename to apps/web/modules/survey/editor/components/end-screen-form.tsx index 4376af093d..7771d93bd4 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm.tsx +++ b/apps/web/modules/survey/editor/components/end-screen-form.tsx @@ -1,7 +1,7 @@ "use client"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; -import { RecallWrapper } from "@/modules/surveys/components/QuestionFormInput/components/RecallWrapper"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; +import { RecallWrapper } from "@/modules/survey/components/question-form-input/components/recall-wrapper"; import { Input } from "@/modules/ui/components/input"; import { Label } from "@/modules/ui/components/label"; import { Switch } from "@/modules/ui/components/switch"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx b/apps/web/modules/survey/editor/components/file-upload-question-form.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx rename to apps/web/modules/survey/editor/components/file-upload-question-form.tsx index 8d3dffdc52..c421d779d0 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx +++ b/apps/web/modules/survey/editor/components/file-upload-question-form.tsx @@ -1,11 +1,12 @@ "use client"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { Button } from "@/modules/ui/components/button"; import { Input } from "@/modules/ui/components/input"; import { useGetBillingInfo } from "@/modules/utils/hooks/useGetBillingInfo"; import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { PlusIcon, XCircleIcon } from "lucide-react"; import Link from "next/link"; @@ -14,13 +15,12 @@ import { toast } from "react-hot-toast"; import { extractLanguageCodes } from "@formbricks/lib/i18n/utils"; import { createI18nString } from "@formbricks/lib/i18n/utils"; import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common"; -import { TProject } from "@formbricks/types/project"; import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; interface FileUploadFormProps { localSurvey: TSurvey; - project?: TProject; + project?: Project; question: TSurveyFileUploadQuestion; questionIdx: number; updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FormStylingSettings.tsx b/apps/web/modules/survey/editor/components/form-styling-settings.tsx similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FormStylingSettings.tsx rename to apps/web/modules/survey/editor/components/form-styling-settings.tsx diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx b/apps/web/modules/survey/editor/components/hidden-fields-card.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx rename to apps/web/modules/survey/editor/components/hidden-fields-card.tsx index dc1a78be0a..62c390a9cb 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx +++ b/apps/web/modules/survey/editor/components/hidden-fields-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { findHiddenFieldUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils"; +import { findHiddenFieldUsedInLogic } from "@/modules/survey/editor/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { Input } from "@/modules/ui/components/input"; import { Label } from "@/modules/ui/components/label"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HowToSendCard.tsx b/apps/web/modules/survey/editor/components/how-to-send-card.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HowToSendCard.tsx rename to apps/web/modules/survey/editor/components/how-to-send-card.tsx index 7b3e9bb559..05e27634fc 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HowToSendCard.tsx +++ b/apps/web/modules/survey/editor/components/how-to-send-card.tsx @@ -5,20 +5,20 @@ import { Badge } from "@/modules/ui/components/badge"; import { Label } from "@/modules/ui/components/label"; import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group"; import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { Environment } from "@prisma/client"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useTranslate } from "@tolgee/react"; import { AlertCircleIcon, CheckIcon, LinkIcon, MonitorIcon } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { cn } from "@formbricks/lib/cn"; -import { TEnvironment } from "@formbricks/types/environment"; import { TSegment } from "@formbricks/types/segment"; import { TSurvey, TSurveyType } from "@formbricks/types/surveys/types"; interface HowToSendCardProps { localSurvey: TSurvey; setLocalSurvey: (survey: TSurvey | ((TSurvey: TSurvey) => TSurvey)) => void; - environment: TEnvironment; + environment: Pick; } export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowToSendCardProps) => { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ImageSurveyBg.tsx b/apps/web/modules/survey/editor/components/image-survey-bg.tsx similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ImageSurveyBg.tsx rename to apps/web/modules/survey/editor/components/image-survey-bg.tsx diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LoadingSkeleton.tsx b/apps/web/modules/survey/editor/components/loading-skeleton.tsx similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LoadingSkeleton.tsx rename to apps/web/modules/survey/editor/components/loading-skeleton.tsx diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorActions.tsx b/apps/web/modules/survey/editor/components/logic-editor-actions.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorActions.tsx rename to apps/web/modules/survey/editor/components/logic-editor-actions.tsx index 580303a435..496013e2ab 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorActions.tsx +++ b/apps/web/modules/survey/editor/components/logic-editor-actions.tsx @@ -7,7 +7,7 @@ import { getActionValueOptions, getActionVariableOptions, hasJumpToQuestionAction, -} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils"; +} from "@/modules/survey/editor/lib/utils"; import { DropdownMenu, DropdownMenuContent, diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorConditions.tsx b/apps/web/modules/survey/editor/components/logic-editor-conditions.tsx similarity index 99% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorConditions.tsx rename to apps/web/modules/survey/editor/components/logic-editor-conditions.tsx index c27248994f..de0f191191 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorConditions.tsx +++ b/apps/web/modules/survey/editor/components/logic-editor-conditions.tsx @@ -5,7 +5,7 @@ import { getConditionValueOptions, getDefaultOperatorForQuestion, getMatchValueProps, -} from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils"; +} from "@/modules/survey/editor/lib/utils"; import { DropdownMenu, DropdownMenuContent, diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx b/apps/web/modules/survey/editor/components/logic-editor.tsx similarity index 92% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx rename to apps/web/modules/survey/editor/components/logic-editor.tsx index d3cb54083c..0da6a1f9eb 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx +++ b/apps/web/modules/survey/editor/components/logic-editor.tsx @@ -1,7 +1,7 @@ "use client"; -import { LogicEditorActions } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorActions"; -import { LogicEditorConditions } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditorConditions"; +import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions"; +import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions"; import { Select, SelectContent, diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MatrixQuestionForm.tsx b/apps/web/modules/survey/editor/components/matrix-question-form.tsx similarity index 99% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MatrixQuestionForm.tsx rename to apps/web/modules/survey/editor/components/matrix-question-form.tsx index ba12bd5266..a0434ac9cd 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MatrixQuestionForm.tsx +++ b/apps/web/modules/survey/editor/components/matrix-question-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { Button } from "@/modules/ui/components/button"; import { Label } from "@/modules/ui/components/label"; import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx b/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx similarity index 97% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx rename to apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx index e366dfe86b..a2f47e5b8d 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx +++ b/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx @@ -1,7 +1,8 @@ "use client"; -import { findOptionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; +import { QuestionOptionChoice } from "@/modules/survey/editor/components/question-option-choice"; +import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { Label } from "@/modules/ui/components/label"; import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select"; @@ -22,7 +23,6 @@ import { TSurveyQuestionTypeEnum, } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; -import { QuestionOptionChoice } from "./QuestionOptionChoice"; interface MultipleChoiceQuestionFormProps { localSurvey: TSurvey; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx b/apps/web/modules/survey/editor/components/nps-question-form.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx rename to apps/web/modules/survey/editor/components/nps-question-form.tsx index d0d2a78951..49bfd088af 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx +++ b/apps/web/modules/survey/editor/components/nps-question-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { Button } from "@/modules/ui/components/button"; import { useAutoAnimate } from "@formkit/auto-animate/react"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx b/apps/web/modules/survey/editor/components/open-question-form.tsx similarity index 99% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx rename to apps/web/modules/survey/editor/components/open-question-form.tsx index f2773a7f5d..ac245e03c9 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx +++ b/apps/web/modules/survey/editor/components/open-question-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { Button } from "@/modules/ui/components/button"; import { Input } from "@/modules/ui/components/input"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx b/apps/web/modules/survey/editor/components/picture-selection-form.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx rename to apps/web/modules/survey/editor/components/picture-selection-form.tsx index ea647eb3db..a2e5c8dbf4 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx +++ b/apps/web/modules/survey/editor/components/picture-selection-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { Button } from "@/modules/ui/components/button"; import { FileInput } from "@/modules/ui/components/file-input"; import { Label } from "@/modules/ui/components/label"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/Placement.tsx b/apps/web/modules/survey/editor/components/placement.tsx similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/Placement.tsx rename to apps/web/modules/survey/editor/components/placement.tsx diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx b/apps/web/modules/survey/editor/components/question-card.tsx similarity index 92% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx rename to apps/web/modules/survey/editor/components/question-card.tsx index 98becbaa7d..d6245e4763 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx +++ b/apps/web/modules/survey/editor/components/question-card.tsx @@ -1,14 +1,29 @@ "use client"; -import { ContactInfoQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ContactInfoQuestionForm"; -import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm"; -import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; +import { AddressQuestionForm } from "@/modules/survey/editor/components/address-question-form"; +import { AdvancedSettings } from "@/modules/survey/editor/components/advanced-settings"; +import { CalQuestionForm } from "@/modules/survey/editor/components/cal-question-form"; +import { ConsentQuestionForm } from "@/modules/survey/editor/components/consent-question-form"; +import { ContactInfoQuestionForm } from "@/modules/survey/editor/components/contact-info-question-form"; +import { CTAQuestionForm } from "@/modules/survey/editor/components/cta-question-form"; +import { DateQuestionForm } from "@/modules/survey/editor/components/date-question-form"; +import { EditorCardMenu } from "@/modules/survey/editor/components/editor-card-menu"; +import { FileUploadQuestionForm } from "@/modules/survey/editor/components/file-upload-question-form"; +import { MatrixQuestionForm } from "@/modules/survey/editor/components/matrix-question-form"; +import { MultipleChoiceQuestionForm } from "@/modules/survey/editor/components/multiple-choice-question-form"; +import { NPSQuestionForm } from "@/modules/survey/editor/components/nps-question-form"; +import { OpenQuestionForm } from "@/modules/survey/editor/components/open-question-form"; +import { PictureSelectionForm } from "@/modules/survey/editor/components/picture-selection-form"; +import { RankingQuestionForm } from "@/modules/survey/editor/components/ranking-question-form"; +import { RatingQuestionForm } from "@/modules/survey/editor/components/rating-question-form"; +import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils"; import { Label } from "@/modules/ui/components/label"; import { Switch } from "@/modules/ui/components/switch"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { Project } from "@prisma/client"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useTranslate } from "@tolgee/react"; import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react"; @@ -16,7 +31,6 @@ import { useState } from "react"; import { cn } from "@formbricks/lib/cn"; import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@formbricks/lib/utils/questions"; import { recallToHeadline } from "@formbricks/lib/utils/recall"; -import { TProject } from "@formbricks/types/project"; import { TI18nString, TSurvey, @@ -25,24 +39,10 @@ import { TSurveyQuestionTypeEnum, } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; -import { AddressQuestionForm } from "./AddressQuestionForm"; -import { AdvancedSettings } from "./AdvancedSettings"; -import { CTAQuestionForm } from "./CTAQuestionForm"; -import { CalQuestionForm } from "./CalQuestionForm"; -import { ConsentQuestionForm } from "./ConsentQuestionForm"; -import { DateQuestionForm } from "./DateQuestionForm"; -import { EditorCardMenu } from "./EditorCardMenu"; -import { FileUploadQuestionForm } from "./FileUploadQuestionForm"; -import { MatrixQuestionForm } from "./MatrixQuestionForm"; -import { MultipleChoiceQuestionForm } from "./MultipleChoiceQuestionForm"; -import { NPSQuestionForm } from "./NPSQuestionForm"; -import { OpenQuestionForm } from "./OpenQuestionForm"; -import { PictureSelectionForm } from "./PictureSelectionForm"; -import { RatingQuestionForm } from "./RatingQuestionForm"; interface QuestionCardProps { localSurvey: TSurvey; - project: TProject; + project: Project; question: TSurveyQuestion; questionIdx: number; moveQuestion: (questionIndex: number, up: boolean) => void; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionOptionChoice.tsx b/apps/web/modules/survey/editor/components/question-option-choice.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionOptionChoice.tsx rename to apps/web/modules/survey/editor/components/question-option-choice.tsx index 3ef837a175..9e0f81a249 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionOptionChoice.tsx +++ b/apps/web/modules/survey/editor/components/question-option-choice.tsx @@ -1,6 +1,6 @@ "use client"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { Button } from "@/modules/ui/components/button"; import { TooltipRenderer } from "@/modules/ui/components/tooltip"; import { useSortable } from "@dnd-kit/sortable"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsDroppable.tsx b/apps/web/modules/survey/editor/components/questions-droppable.tsx similarity index 94% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsDroppable.tsx rename to apps/web/modules/survey/editor/components/questions-droppable.tsx index f4318eaba7..bdd3baff9e 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsDroppable.tsx +++ b/apps/web/modules/survey/editor/components/questions-droppable.tsx @@ -1,13 +1,13 @@ +import { QuestionCard } from "@/modules/survey/editor/components/question-card"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { useAutoAnimate } from "@formkit/auto-animate/react"; -import { TProject } from "@formbricks/types/project"; +import { Project } from "@prisma/client"; import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; -import { QuestionCard } from "./QuestionCard"; interface QuestionsDraggableProps { localSurvey: TSurvey; - project: TProject; + project: Project; moveQuestion: (questionIndex: number, up: boolean) => void; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; deleteQuestion: (questionIdx: number) => void; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx b/apps/web/modules/survey/editor/components/questions-view.tsx similarity index 95% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx rename to apps/web/modules/survey/editor/components/questions-view.tsx index 2bf8aff647..3d000d0cbd 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx +++ b/apps/web/modules/survey/editor/components/questions-view.tsx @@ -1,10 +1,15 @@ "use client"; -import { AddEndingCardButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton"; -import { SurveyVariablesCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard"; -import { findQuestionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils"; import { getDefaultEndingCard } from "@/app/lib/templates"; import { MultiLanguageCard } from "@/modules/ee/multi-language-surveys/components/multi-language-card"; +import { AddEndingCardButton } from "@/modules/survey/editor/components/add-ending-card-button"; +import { AddQuestionButton } from "@/modules/survey/editor/components/add-question-button"; +import { EditEndingCard } from "@/modules/survey/editor/components/edit-ending-card"; +import { EditWelcomeCard } from "@/modules/survey/editor/components/edit-welcome-card"; +import { HiddenFieldsCard } from "@/modules/survey/editor/components/hidden-fields-card"; +import { QuestionsDroppable } from "@/modules/survey/editor/components/questions-droppable"; +import { SurveyVariablesCard } from "@/modules/survey/editor/components/survey-variables-card"; +import { findQuestionUsedInLogic } from "@/modules/survey/editor/lib/utils"; import { DndContext, DragEndEvent, @@ -16,6 +21,7 @@ import { import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { createId } from "@paralleldrive/cuid2"; +import { Language, Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import React, { SetStateAction, useEffect, useMemo } from "react"; import toast from "react-hot-toast"; @@ -24,7 +30,6 @@ import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone"; import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils"; import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall"; import { TOrganizationBillingPlan } from "@formbricks/types/organizations"; -import { TProject } from "@formbricks/types/project"; import { TConditionGroup, TSingleCondition, @@ -41,18 +46,14 @@ import { validateQuestion, validateSurveyQuestionsInBatch, } from "../lib/validation"; -import { AddQuestionButton } from "./AddQuestionButton"; -import { EditEndingCard } from "./EditEndingCard"; -import { EditWelcomeCard } from "./EditWelcomeCard"; -import { HiddenFieldsCard } from "./HiddenFieldsCard"; -import { QuestionsDroppable } from "./QuestionsDroppable"; interface QuestionsViewProps { localSurvey: TSurvey; setLocalSurvey: React.Dispatch>; activeQuestionId: TSurveyQuestionId | null; setActiveQuestionId: (questionId: TSurveyQuestionId | null) => void; - project: TProject; + project: Project; + projectLanguages: Language[]; invalidQuestions: string[] | null; setInvalidQuestions: React.Dispatch>; selectedLanguageCode: string; @@ -70,6 +71,7 @@ export const QuestionsView = ({ localSurvey, setLocalSurvey, project, + projectLanguages, invalidQuestions, setInvalidQuestions, setSelectedLanguageCode, @@ -517,7 +519,7 @@ export const QuestionsView = ({ >; setOpen: React.Dispatch>; @@ -24,13 +24,13 @@ export const SavedActionsTab = ({ const availableActions = actionClasses.filter( (actionClass) => !localSurvey.triggers.some((trigger) => trigger.actionClass.id === actionClass.id) ); - const [filteredActionClasses, setFilteredActionClasses] = useState(availableActions); + const [filteredActionClasses, setFilteredActionClasses] = useState(availableActions); const codeActions = filteredActionClasses.filter((actionClass) => actionClass.type === "code"); const noCodeActions = filteredActionClasses.filter((actionClass) => actionClass.type === "noCode"); const automaticActions = filteredActionClasses.filter((actionClass) => actionClass.type === "automatic"); - const handleActionClick = (action: TActionClass) => { + const handleActionClick = (action: ActionClass) => { setLocalSurvey((prev) => ({ ...prev, triggers: prev.triggers.concat({ actionClass: action }), diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SettingsView.tsx b/apps/web/modules/survey/editor/components/settings-view.tsx similarity index 78% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SettingsView.tsx rename to apps/web/modules/survey/editor/components/settings-view.tsx index 5182abba35..9b21cccfa5 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SettingsView.tsx +++ b/apps/web/modules/survey/editor/components/settings-view.tsx @@ -1,27 +1,25 @@ -import { TargetingLockedCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/TargetingLockedCard"; import { TargetingCard } from "@/modules/ee/contacts/segments/components/targeting-card"; import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; -import { TActionClass } from "@formbricks/types/action-classes"; +import { HowToSendCard } from "@/modules/survey/editor/components/how-to-send-card"; +import { RecontactOptionsCard } from "@/modules/survey/editor/components/recontact-options-card"; +import { ResponseOptionsCard } from "@/modules/survey/editor/components/response-options-card"; +import { SurveyPlacementCard } from "@/modules/survey/editor/components/survey-placement-card"; +import { TargetingLockedCard } from "@/modules/survey/editor/components/targeting-locked-card"; +import { WhenToSendCard } from "@/modules/survey/editor/components/when-to-send-card"; +import { ActionClass, Environment, OrganizationRole } from "@prisma/client"; import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; -import { TEnvironment } from "@formbricks/types/environment"; -import { TOrganizationRole } from "@formbricks/types/memberships"; import { TSegment } from "@formbricks/types/segment"; import { TSurvey } from "@formbricks/types/surveys/types"; -import { HowToSendCard } from "./HowToSendCard"; -import { RecontactOptionsCard } from "./RecontactOptionsCard"; -import { ResponseOptionsCard } from "./ResponseOptionsCard"; -import { SurveyPlacementCard } from "./SurveyPlacementCard"; -import { WhenToSendCard } from "./WhenToSendCard"; interface SettingsViewProps { - environment: TEnvironment; + environment: Pick; localSurvey: TSurvey; setLocalSurvey: (survey: TSurvey) => void; - actionClasses: TActionClass[]; + actionClasses: ActionClass[]; contactAttributeKeys: TContactAttributeKey[]; segments: TSegment[]; responseCount: number; - membershipRole?: TOrganizationRole; + membershipRole?: OrganizationRole; isUserTargetingAllowed?: boolean; projectPermission: TTeamPermission | null; isFormbricksCloud: boolean; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/StylingView.tsx b/apps/web/modules/survey/editor/components/styling-view.tsx similarity index 93% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/StylingView.tsx rename to apps/web/modules/survey/editor/components/styling-view.tsx index 55dfb3ff1c..7c8f38d49a 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/StylingView.tsx +++ b/apps/web/modules/survey/editor/components/styling-view.tsx @@ -1,7 +1,10 @@ "use client"; +import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings"; import { AlertDialog } from "@/modules/ui/components/alert-dialog"; +import { BackgroundStylingCard } from "@/modules/ui/components/background-styling-card"; import { Button } from "@/modules/ui/components/button"; +import { CardStylingSettings } from "@/modules/ui/components/card-styling-settings"; import { FormControl, FormDescription, @@ -11,6 +14,7 @@ import { FormProvider, } from "@/modules/ui/components/form"; import { Switch } from "@/modules/ui/components/switch"; +import { Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { RotateCcwIcon } from "lucide-react"; import Link from "next/link"; @@ -18,16 +22,12 @@ import React, { useEffect, useMemo, useState } from "react"; import { UseFormReturn, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { defaultStyling } from "@formbricks/lib/styling/constants"; -import { TEnvironment } from "@formbricks/types/environment"; -import { TProject, TProjectStyling } from "@formbricks/types/project"; +import { TProjectStyling } from "@formbricks/types/project"; import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types"; -import { BackgroundStylingCard } from "./BackgroundStylingCard"; -import { CardStylingSettings } from "./CardStylingSettings"; -import { FormStylingSettings } from "./FormStylingSettings"; interface StylingViewProps { - environment: TEnvironment; - project: TProject; + environmentId: string; + project: Project; localSurvey: TSurvey; setLocalSurvey: React.Dispatch>; colors: string[]; @@ -41,7 +41,7 @@ interface StylingViewProps { export const StylingView = ({ colors, - environment, + environmentId, project, localSurvey, setLocalSurvey, @@ -204,7 +204,7 @@ export const StylingView = ({ {t("environments.surveys.edit.adjust_the_theme_in_the")}{" "} {t("common.look_and_feel")} diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditorTabs.tsx b/apps/web/modules/survey/editor/components/survey-editor-tabs.tsx similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditorTabs.tsx rename to apps/web/modules/survey/editor/components/survey-editor-tabs.tsx diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx b/apps/web/modules/survey/editor/components/survey-editor.tsx similarity index 88% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx rename to apps/web/modules/survey/editor/components/survey-editor.tsx index 68f907c813..666cf74a95 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx +++ b/apps/web/modules/survey/editor/components/survey-editor.tsx @@ -1,38 +1,35 @@ "use client"; import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; -import { FollowUpsView } from "@/modules/survey-follow-ups/components/follow-ups-view"; +import { FollowUpsView } from "@/modules/survey/follow-ups/components/follow-ups-view"; +import { LoadingSkeleton } from "@/modules/survey/editor/components/loading-skeleton"; +import { QuestionsView } from "@/modules/survey/editor/components/questions-view"; +import { SettingsView } from "@/modules/survey/editor/components/settings-view"; +import { StylingView } from "@/modules/survey/editor/components/styling-view"; +import { SurveyEditorTabs } from "@/modules/survey/editor/components/survey-editor-tabs"; +import { SurveyMenuBar } from "@/modules/survey/editor/components/survey-menu-bar"; import { PreviewSurvey } from "@/modules/ui/components/preview-survey"; +import { ActionClass, Environment, Language, OrganizationRole, Project } from "@prisma/client"; import { useCallback, useEffect, useRef, useState } from "react"; import { extractLanguageCodes, getEnabledLanguages } from "@formbricks/lib/i18n/utils"; import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone"; import { useDocumentVisibility } from "@formbricks/lib/useDocumentVisibility"; -import { TActionClass } from "@formbricks/types/action-classes"; import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; -import { TEnvironment } from "@formbricks/types/environment"; -import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationBillingPlan } from "@formbricks/types/organizations"; -import { TProject } from "@formbricks/types/project"; import { TSegment } from "@formbricks/types/segment"; import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { refetchProjectAction } from "../actions"; -import { LoadingSkeleton } from "./LoadingSkeleton"; -import { QuestionsView } from "./QuestionsView"; -import { SettingsView } from "./SettingsView"; -import { StylingView } from "./StylingView"; -import { SurveyEditorTabs } from "./SurveyEditorTabs"; -import { SurveyMenuBar } from "./SurveyMenuBar"; interface SurveyEditorProps { survey: TSurvey; - project: TProject; - environment: TEnvironment; - actionClasses: TActionClass[]; + project: Project; + environment: Pick; + actionClasses: ActionClass[]; contactAttributeKeys: TContactAttributeKey[]; segments: TSegment[]; responseCount: number; - membershipRole?: TOrganizationRole; + membershipRole?: OrganizationRole; colors: string[]; isUserTargetingAllowed?: boolean; isMultiLanguageAllowed?: boolean; @@ -43,6 +40,7 @@ interface SurveyEditorProps { locale: TUserLocale; projectPermission: TTeamPermission | null; mailFrom: string; + projectLanguages: Language[]; isSurveyFollowUpsAllowed: boolean; userEmail: string; } @@ -50,6 +48,7 @@ interface SurveyEditorProps { export const SurveyEditor = ({ survey, project, + projectLanguages, environment, actionClasses, contactAttributeKeys, @@ -75,7 +74,7 @@ export const SurveyEditor = ({ const [invalidQuestions, setInvalidQuestions] = useState(null); const [selectedLanguageCode, setSelectedLanguageCode] = useState("default"); const surveyEditorRef = useRef(null); - const [localProject, setLocalProject] = useState(project); + const [localProject, setLocalProject] = useState(project); const [styling, setStyling] = useState(localSurvey?.styling); const [localStylingChanges, setLocalStylingChanges] = useState(null); @@ -148,7 +147,7 @@ export const SurveyEditor = ({ setLocalSurvey={setLocalSurvey} localSurvey={localSurvey} survey={survey} - environment={environment} + environmentId={environment.id} activeId={activeView} setActiveId={setActiveView} setInvalidQuestions={setInvalidQuestions} @@ -178,6 +177,7 @@ export const SurveyEditor = ({ activeQuestionId={activeQuestionId} setActiveQuestionId={setActiveQuestionId} project={localProject} + projectLanguages={projectLanguages} invalidQuestions={invalidQuestions} setInvalidQuestions={setInvalidQuestions} selectedLanguageCode={selectedLanguageCode ? selectedLanguageCode : "default"} @@ -193,7 +193,7 @@ export const SurveyEditor = ({ {activeView === "styling" && project.styling.allowStyleOverwrite && ( void; - environment: TEnvironment; + environmentId: string; activeId: TSurveyEditorTabs; setActiveId: React.Dispatch>; setInvalidQuestions: React.Dispatch>; - project: TProject; + project: Project; responseCount: number; selectedLanguageCode: string; setSelectedLanguageCode: (selectedLanguage: string) => void; @@ -46,7 +45,7 @@ interface SurveyMenuBarProps { export const SurveyMenuBar = ({ localSurvey, survey, - environment, + environmentId, setLocalSurvey, activeId, setActiveId, @@ -295,7 +294,7 @@ export const SurveyMenuBar = ({ segment, }); setIsSurveyPublishing(false); - router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary?success=true`); + router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`); } catch (error) { console.error(error); toast.error(t("environments.surveys.edit.error_publishing_survey")); diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyPlacementCard.tsx b/apps/web/modules/survey/editor/components/survey-placement-card.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyPlacementCard.tsx rename to apps/web/modules/survey/editor/components/survey-placement-card.tsx index ba46a45e72..2be85928cf 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyPlacementCard.tsx +++ b/apps/web/modules/survey/editor/components/survey-placement-card.tsx @@ -1,5 +1,6 @@ "use client"; +import { Placement } from "@/modules/survey/editor/components/placement"; import { Label } from "@/modules/ui/components/label"; import { Switch } from "@/modules/ui/components/switch"; import { useAutoAnimate } from "@formkit/auto-animate/react"; @@ -10,7 +11,6 @@ import Link from "next/link"; import { useState } from "react"; import { TPlacement } from "@formbricks/types/common"; import { TSurvey, TSurveyProjectOverwrites } from "@formbricks/types/surveys/types"; -import { Placement } from "./Placement"; interface SurveyPlacementCardProps { localSurvey: TSurvey; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCardItem.tsx b/apps/web/modules/survey/editor/components/survey-variables-card-item.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCardItem.tsx rename to apps/web/modules/survey/editor/components/survey-variables-card-item.tsx index e175d1e546..eac1ba3627 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCardItem.tsx +++ b/apps/web/modules/survey/editor/components/survey-variables-card-item.tsx @@ -1,6 +1,6 @@ "use client"; -import { findVariableUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils"; +import { findVariableUsedInLogic } from "@/modules/survey/editor/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { FormControl, FormField, FormItem, FormProvider } from "@/modules/ui/components/form"; import { Input } from "@/modules/ui/components/input"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard.tsx b/apps/web/modules/survey/editor/components/survey-variables-card.tsx similarity index 96% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard.tsx rename to apps/web/modules/survey/editor/components/survey-variables-card.tsx index 4276a92e95..25c507ea1d 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard.tsx +++ b/apps/web/modules/survey/editor/components/survey-variables-card.tsx @@ -1,12 +1,12 @@ "use client"; +import { SurveyVariablesCardItem } from "@/modules/survey/editor/components/survey-variables-card-item"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useTranslate } from "@tolgee/react"; import { FileDigitIcon } from "lucide-react"; import { cn } from "@formbricks/lib/cn"; import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types"; -import { SurveyVariablesCardItem } from "./SurveyVariablesCardItem"; interface SurveyVariablesCardProps { localSurvey: TSurvey; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/TargetingLockedCard.tsx b/apps/web/modules/survey/editor/components/targeting-locked-card.tsx similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/TargetingLockedCard.tsx rename to apps/web/modules/survey/editor/components/targeting-locked-card.tsx diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/UnsplashImages.tsx b/apps/web/modules/survey/editor/components/unsplash-images.tsx similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/UnsplashImages.tsx rename to apps/web/modules/survey/editor/components/unsplash-images.tsx diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/UpdateQuestionId.tsx b/apps/web/modules/survey/editor/components/update-question-id.tsx similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/UpdateQuestionId.tsx rename to apps/web/modules/survey/editor/components/update-question-id.tsx diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx b/apps/web/modules/survey/editor/components/when-to-send-card.tsx similarity index 97% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx rename to apps/web/modules/survey/editor/components/when-to-send-card.tsx index dab3be7d05..13fe05d3c8 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx +++ b/apps/web/modules/survey/editor/components/when-to-send-card.tsx @@ -2,10 +2,12 @@ import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; +import { AddActionModal } from "@/modules/survey/editor/components/add-action-modal"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { Button } from "@/modules/ui/components/button"; import { Input } from "@/modules/ui/components/input"; import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { ActionClass, OrganizationRole } from "@prisma/client"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useTranslate } from "@tolgee/react"; import { @@ -18,17 +20,14 @@ import { } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; -import { TActionClass } from "@formbricks/types/action-classes"; -import { TOrganizationRole } from "@formbricks/types/memberships"; import { TSurvey } from "@formbricks/types/surveys/types"; -import { AddActionModal } from "./AddActionModal"; interface WhenToSendCardProps { localSurvey: TSurvey; setLocalSurvey: React.Dispatch>; environmentId: string; - propActionClasses: TActionClass[]; - membershipRole?: TOrganizationRole; + propActionClasses: ActionClass[]; + membershipRole?: OrganizationRole; projectPermission: TTeamPermission | null; } @@ -43,7 +42,7 @@ export const WhenToSendCard = ({ const { t } = useTranslate(); const [open, setOpen] = useState(localSurvey.type === "app" ? true : false); const [isAddActionModalOpen, setAddActionModalOpen] = useState(false); - const [actionClasses, setActionClasses] = useState(propActionClasses); + const [actionClasses, setActionClasses] = useState(propActionClasses); const [randomizerToggle, setRandomizerToggle] = useState(localSurvey.displayPercentage ? true : false); const { isMember } = getAccessFlags(membershipRole); diff --git a/apps/web/modules/survey/editor/lib/action-class.ts b/apps/web/modules/survey/editor/lib/action-class.ts new file mode 100644 index 0000000000..5a4a3c3297 --- /dev/null +++ b/apps/web/modules/survey/editor/lib/action-class.ts @@ -0,0 +1,39 @@ +import { ActionClass, Prisma } from "@prisma/client"; +import { prisma } from "@formbricks/database"; +import { actionClassCache } from "@formbricks/lib/actionClass/cache"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TActionClassInput } from "@formbricks/types/action-classes"; + +export const createActionClass = async ( + environmentId: string, + actionClass: TActionClassInput +): Promise => { + const { environmentId: _, ...actionClassInput } = actionClass; + + try { + const actionClassPrisma = await prisma.actionClass.create({ + data: { + ...actionClassInput, + environment: { connect: { id: environmentId } }, + key: actionClassInput.type === "code" ? actionClassInput.key : undefined, + noCodeConfig: actionClassInput.type === "noCode" ? actionClassInput.noCodeConfig || {} : undefined, + }, + }); + + actionClassCache.revalidate({ + name: actionClassPrisma.name, + environmentId: actionClassPrisma.environmentId, + id: actionClassPrisma.id, + }); + + return actionClassPrisma; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + throw new DatabaseError( + `Action with ${error.meta?.target?.[0]} ${actionClass[error.meta?.target?.[0]]} already exists` + ); + } + + throw new DatabaseError(`Database error when creating an action for environment ${environmentId}`); + } +}; \ No newline at end of file diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/logicRuleEngine.ts b/apps/web/modules/survey/editor/lib/logic-rule-engine.ts similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/logicRuleEngine.ts rename to apps/web/modules/survey/editor/lib/logic-rule-engine.ts diff --git a/apps/web/modules/survey/editor/lib/project.ts b/apps/web/modules/survey/editor/lib/project.ts new file mode 100644 index 0000000000..78c1df8d9a --- /dev/null +++ b/apps/web/modules/survey/editor/lib/project.ts @@ -0,0 +1,57 @@ +import { Language, Prisma, Project } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { projectCache } from "@formbricks/lib/project/cache"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; + +export const getProject = reactCache( + async (projectId: string): Promise => + cache( + async () => { + try { + const projectPrisma = await prisma.project.findUnique({ + where: { + id: projectId, + }, + }); + + return projectPrisma; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } + }, + [`survey-editor-getProject-${projectId}`], + { + tags: [projectCache.tag.byId(projectId)], + } + )() +); + +export const getProjectLanguages = reactCache( + async (projectId: string): Promise => + cache( + async () => { + const project = await prisma.project.findUnique({ + where: { + id: projectId, + }, + select: { + languages: true, + }, + }); + if (!project) { + throw new ResourceNotFoundError("Project not found", projectId); + } + return project.languages; + }, + [`survey-editor-getProjectLanguages-${projectId}`], + { + tags: [projectCache.tag.byId(projectId)], + } + )() +); diff --git a/apps/web/modules/survey/editor/lib/survey.ts b/apps/web/modules/survey/editor/lib/survey.ts new file mode 100644 index 0000000000..e594428698 --- /dev/null +++ b/apps/web/modules/survey/editor/lib/survey.ts @@ -0,0 +1,381 @@ +import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils"; +import { handleTriggerUpdates } from "@/modules/survey/editor/lib/utils"; +import { getActionClasses } from "@/modules/survey/lib/action-class"; +import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization"; +import { getSurvey, selectSurvey } from "@/modules/survey/lib/survey"; +import { getInsightsEnabled } from "@/modules/survey/lib/utils"; +import { doesSurveyHasOpenTextQuestion } from "@/modules/survey/lib/utils"; +import { Prisma, Survey } from "@prisma/client"; +import { prisma } from "@formbricks/database"; +import { segmentCache } from "@formbricks/lib/cache/segment"; +import { surveyCache } from "@formbricks/lib/survey/cache"; +import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TSegment, ZSegmentFilters } from "@formbricks/types/segment"; +import { TSurvey, TSurveyOpenTextQuestion } from "@formbricks/types/surveys/types"; + +export const updateSurvey = async (updatedSurvey: TSurvey): Promise => { + try { + const surveyId = updatedSurvey.id; + let data: any = {}; + + const actionClasses = await getActionClasses(updatedSurvey.environmentId); + const currentSurvey = await getSurvey(surveyId); + + if (!currentSurvey) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } = + updatedSurvey; + + if (languages) { + // Process languages update logic here + // Extract currentLanguageIds and updatedLanguageIds + const currentLanguageIds = currentSurvey.languages + ? currentSurvey.languages.map((l) => l.language.id) + : []; + const updatedLanguageIds = + languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : []; + const enabledLanguageIds = languages.map((language) => { + if (language.enabled) return language.language.id; + }); + + // Determine languages to add and remove + const languagesToAdd = updatedLanguageIds.filter((id) => !currentLanguageIds.includes(id)); + const languagesToRemove = currentLanguageIds.filter((id) => !updatedLanguageIds.includes(id)); + + const defaultLanguageId = updatedSurvey.languages.find((l) => l.default)?.language.id; + + // Prepare data for Prisma update + data.languages = {}; + + // Update existing languages for default value changes + data.languages.updateMany = currentSurvey.languages.map((surveyLanguage) => ({ + where: { languageId: surveyLanguage.language.id }, + data: { + default: surveyLanguage.language.id === defaultLanguageId, + enabled: enabledLanguageIds.includes(surveyLanguage.language.id), + }, + })); + + // Add new languages + if (languagesToAdd.length > 0) { + data.languages.create = languagesToAdd.map((languageId) => ({ + languageId: languageId, + default: languageId === defaultLanguageId, + enabled: enabledLanguageIds.includes(languageId), + })); + } + + // Remove languages no longer associated with the survey + if (languagesToRemove.length > 0) { + data.languages.deleteMany = languagesToRemove.map((languageId) => ({ + languageId: languageId, + enabled: enabledLanguageIds.includes(languageId), + })); + } + } + + if (triggers) { + data.triggers = handleTriggerUpdates(triggers, currentSurvey.triggers, actionClasses); + } + + // if the survey body has type other than "app" but has a private segment, we delete that segment, and if it has a public segment, we disconnect from to the survey + if (segment) { + if (type === "app") { + // parse the segment filters: + const parsedFilters = ZSegmentFilters.safeParse(segment.filters); + if (!parsedFilters.success) { + throw new InvalidInputError("Invalid user segment filters"); + } + + try { + // update the segment: + let updatedInput: Prisma.SegmentUpdateInput = { + ...segment, + surveys: undefined, + }; + + if (segment.surveys) { + updatedInput = { + ...segment, + surveys: { + connect: segment.surveys.map((surveyId) => ({ id: surveyId })), + }, + }; + } + + const updatedSegment = await prisma.segment.update({ + where: { id: segment.id }, + data: updatedInput, + select: { + surveys: { select: { id: true } }, + environmentId: true, + id: true, + }, + }); + + segmentCache.revalidate({ id: updatedSegment.id, environmentId: updatedSegment.environmentId }); + updatedSegment.surveys.map((survey) => surveyCache.revalidate({ id: survey.id })); + } catch (error) { + console.error(error); + throw new Error("Error updating survey"); + } + } else { + if (segment.isPrivate) { + // disconnect the private segment first and then delete: + await prisma.segment.update({ + where: { id: segment.id }, + data: { + surveys: { + disconnect: { + id: surveyId, + }, + }, + }, + }); + + // delete the private segment: + await prisma.segment.delete({ + where: { + id: segment.id, + }, + }); + } else { + await prisma.survey.update({ + where: { + id: surveyId, + }, + data: { + segment: { + disconnect: true, + }, + }, + }); + } + } + + segmentCache.revalidate({ + id: segment.id, + environmentId: segment.environmentId, + }); + } else if (type === "app") { + if (!currentSurvey.segment) { + await prisma.survey.update({ + where: { + id: surveyId, + }, + data: { + segment: { + connectOrCreate: { + where: { + environmentId_title: { + environmentId, + title: surveyId, + }, + }, + create: { + title: surveyId, + isPrivate: true, + filters: [], + environment: { + connect: { + id: environmentId, + }, + }, + }, + }, + }, + }, + }); + + segmentCache.revalidate({ + environmentId, + }); + } + } + + if (followUps) { + // Separate follow-ups into categories based on deletion flag + const deletedFollowUps = followUps.filter((followUp) => followUp.deleted); + const nonDeletedFollowUps = followUps.filter((followUp) => !followUp.deleted); + + // Get set of existing follow-up IDs from currentSurvey + const existingFollowUpIds = new Set(currentSurvey.followUps.map((f) => f.id)); + + // Separate non-deleted follow-ups into new and existing + const existingFollowUps = nonDeletedFollowUps.filter((followUp) => + existingFollowUpIds.has(followUp.id) + ); + const newFollowUps = nonDeletedFollowUps.filter((followUp) => !existingFollowUpIds.has(followUp.id)); + + data.followUps = { + // Update existing follow-ups + updateMany: existingFollowUps.map((followUp) => ({ + where: { + id: followUp.id, + }, + data: { + name: followUp.name, + trigger: followUp.trigger, + action: followUp.action, + }, + })), + // Create new follow-ups + createMany: + newFollowUps.length > 0 + ? { + data: newFollowUps.map((followUp) => ({ + name: followUp.name, + trigger: followUp.trigger, + action: followUp.action, + })), + } + : undefined, + // Delete follow-ups marked as deleted, regardless of whether they exist in DB + deleteMany: + deletedFollowUps.length > 0 + ? deletedFollowUps.map((followUp) => ({ + id: followUp.id, + })) + : undefined, + }; + } + + data.questions = questions.map((question) => { + const { isDraft, ...rest } = question; + return rest; + }); + + const organizationId = await getOrganizationIdFromEnvironmentId(environmentId); + const organization = await getOrganizationAIKeys(organizationId); + if (!organization) { + throw new ResourceNotFoundError("Organization", null); + } + + //AI Insights + const isAIEnabled = await getIsAIEnabled(organization); + if (isAIEnabled) { + if (doesSurveyHasOpenTextQuestion(data.questions ?? [])) { + const openTextQuestions = data.questions?.filter((question) => question.type === "openText") ?? []; + const currentSurveyOpenTextQuestions = currentSurvey.questions?.filter( + (question) => question.type === "openText" + ); + + // find the questions that have been updated or added + const questionsToCheckForInsights: Survey["questions"] = []; + + for (const question of openTextQuestions) { + const existingQuestion = currentSurveyOpenTextQuestions?.find((ques) => ques.id === question.id) as + | TSurveyOpenTextQuestion + | undefined; + const isExistingQuestion = !!existingQuestion; + + if ( + isExistingQuestion && + question.headline.default === existingQuestion.headline.default && + existingQuestion.insightsEnabled !== undefined + ) { + continue; + } else { + questionsToCheckForInsights.push(question); + } + } + + if (questionsToCheckForInsights.length > 0) { + const insightsEnabledValues = await Promise.all( + questionsToCheckForInsights.map(async (question) => { + const insightsEnabled = await getInsightsEnabled(question); + + return { id: question.id, insightsEnabled }; + }) + ); + + data.questions = data.questions?.map((question) => { + const index = insightsEnabledValues.findIndex((item) => item.id === question.id); + if (index !== -1) { + return { + ...question, + insightsEnabled: insightsEnabledValues[index].insightsEnabled, + }; + } + + return question; + }); + } + } + } else { + // check if an existing question got changed that had insights enabled + const insightsEnabledOpenTextQuestions = currentSurvey.questions?.filter( + (question) => question.type === "openText" && question.insightsEnabled !== undefined + ); + // if question headline changed, remove insightsEnabled + for (const question of insightsEnabledOpenTextQuestions) { + const updatedQuestion = data.questions?.find((q) => q.id === question.id); + if (updatedQuestion && updatedQuestion.headline.default !== question.headline.default) { + updatedQuestion.insightsEnabled = undefined; + } + } + } + + surveyData.updatedAt = new Date(); + + data = { + ...surveyData, + ...data, + type, + }; + + // Remove scheduled status when runOnDate is not set + if (data.status === "scheduled" && data.runOnDate === null) { + data.status = "inProgress"; + } + // Set scheduled status when runOnDate is set and in the future on completed surveys + if ( + (data.status === "completed" || data.status === "paused" || data.status === "inProgress") && + data.runOnDate && + data.runOnDate > new Date() + ) { + data.status = "scheduled"; + } + + delete data.createdBy; + const prismaSurvey = await prisma.survey.update({ + where: { id: surveyId }, + data, + select: selectSurvey, + }); + + let surveySegment: TSegment | null = null; + if (prismaSurvey.segment) { + surveySegment = { + ...prismaSurvey.segment, + surveys: prismaSurvey.segment.surveys.map((survey) => survey.id), + }; + } + + // TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration + // @ts-expect-error + const modifiedSurvey: TSurvey = { + ...prismaSurvey, // Properties from prismaSurvey + displayPercentage: Number(prismaSurvey.displayPercentage) || null, + segment: surveySegment, + }; + + surveyCache.revalidate({ + id: modifiedSurvey.id, + environmentId: modifiedSurvey.environmentId, + segmentId: modifiedSurvey.segment?.id, + resultShareKey: currentSurvey.resultShareKey ?? undefined, + }); + + return modifiedSurvey; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + + throw error; + } +}; diff --git a/apps/web/modules/survey/editor/lib/user.ts b/apps/web/modules/survey/editor/lib/user.ts new file mode 100644 index 0000000000..66d5f253a9 --- /dev/null +++ b/apps/web/modules/survey/editor/lib/user.ts @@ -0,0 +1,67 @@ +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { userCache } from "@formbricks/lib/user/cache"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TUserLocale } from "@formbricks/types/user"; + +export const getUserEmail = reactCache( + (userId: string): Promise => + cache( + async () => { + try { + const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } }); + + if (!user) { + return null; + } + + return user.email; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`survey-editor-getUserEmail-${userId}`], + { + tags: [userCache.tag.byId(userId)], + } + )() +); + +export const getUserLocale = reactCache( + async (id: string): Promise => + cache( + async () => { + try { + const user = await prisma.user.findUnique({ + where: { + id, + }, + select: { + locale: true, + }, + }); + + if (!user) { + return undefined; + } + return user.locale; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`survey-editor-getUserLocale-${id}`], + { + tags: [userCache.tag.byId(id)], + } + )() +); diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils.tsx b/apps/web/modules/survey/editor/lib/utils.tsx similarity index 93% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils.tsx rename to apps/web/modules/survey/editor/lib/utils.tsx index de6ef46f03..347fc3a97e 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils.tsx +++ b/apps/web/modules/survey/editor/lib/utils.tsx @@ -1,11 +1,15 @@ +import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger"; import { TComboboxGroupedOption, TComboboxOption } from "@/modules/ui/components/input-combo-box"; +import { ActionClass } from "@prisma/client"; import { TFnType } from "@tolgee/react"; import { EyeOffIcon, FileDigitIcon, FileType2Icon } from "lucide-react"; import { HTMLInputTypeAttribute } from "react"; import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; +import { surveyCache } from "@formbricks/lib/survey/cache"; import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils"; import { getQuestionTypes } from "@formbricks/lib/utils/questions"; import { recallToHeadline } from "@formbricks/lib/utils/recall"; +import { InvalidInputError } from "@formbricks/types/errors"; import { TConditionGroup, TLeftOperand, @@ -21,7 +25,7 @@ import { TSurveyQuestionTypeEnum, TSurveyVariable, } from "@formbricks/types/surveys/types"; -import { TLogicRuleOption, getLogicRules } from "./logicRuleEngine"; +import { TLogicRuleOption, getLogicRules } from "./logic-rule-engine"; // formats the text to highlight specific parts of the text with slashes export const formatTextWithSlashes = (text: string) => { @@ -1171,3 +1175,69 @@ export const findEndingCardUsedInLogic = (survey: TSurvey, endingCardId: string) (question) => question.logicFallback === endingCardId || question.logic?.some(isUsedInLogicRule) ); }; + +const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => { + if (!triggers) return; + + // check if all the triggers are valid + triggers.forEach((trigger) => { + if (!actionClasses.find((actionClass) => actionClass.id === trigger.actionClass.id)) { + throw new InvalidInputError("Invalid trigger id"); + } + }); + + // check if all the triggers are unique + const triggerIds = triggers.map((trigger) => trigger.actionClass.id); + + if (new Set(triggerIds).size !== triggerIds.length) { + throw new InvalidInputError("Duplicate trigger id"); + } +}; + +export const handleTriggerUpdates = ( + updatedTriggers: TSurvey["triggers"], + currentTriggers: TSurvey["triggers"], + actionClasses: ActionClass[] +) => { + if (!updatedTriggers) return {}; + checkTriggersValidity(updatedTriggers, actionClasses); + + const currentTriggerIds = currentTriggers.map((trigger) => trigger.actionClass.id); + const updatedTriggerIds = updatedTriggers.map((trigger) => trigger.actionClass.id); + + // added triggers are triggers that are not in the current triggers and are there in the new triggers + const addedTriggers = updatedTriggers.filter( + (trigger) => !currentTriggerIds.includes(trigger.actionClass.id) + ); + + // deleted triggers are triggers that are not in the new triggers and are there in the current triggers + const deletedTriggers = currentTriggers.filter( + (trigger) => !updatedTriggerIds.includes(trigger.actionClass.id) + ); + + // Construct the triggers update object + const triggersUpdate: TriggerUpdate = {}; + + if (addedTriggers.length > 0) { + triggersUpdate.create = addedTriggers.map((trigger) => ({ + actionClassId: trigger.actionClass.id, + })); + } + + if (deletedTriggers.length > 0) { + // disconnect the public triggers from the survey + triggersUpdate.deleteMany = { + actionClassId: { + in: deletedTriggers.map((trigger) => trigger.actionClass.id), + }, + }; + } + + [...addedTriggers, ...deletedTriggers].forEach((trigger) => { + surveyCache.revalidate({ + actionClassId: trigger.actionClass.id, + }); + }); + + return triggersUpdate; +}; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation.ts b/apps/web/modules/survey/editor/lib/validation.ts similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation.ts rename to apps/web/modules/survey/editor/lib/validation.ts diff --git a/apps/web/modules/survey/editor/loading.tsx b/apps/web/modules/survey/editor/loading.tsx new file mode 100644 index 0000000000..f892f14377 --- /dev/null +++ b/apps/web/modules/survey/editor/loading.tsx @@ -0,0 +1,5 @@ +import { LoadingSkeleton } from "./components/loading-skeleton"; + +export const SurveyEditorLoading = () => { + return ; +}; diff --git a/apps/web/modules/survey/editor/page.tsx b/apps/web/modules/survey/editor/page.tsx new file mode 100644 index 0000000000..c7967422a9 --- /dev/null +++ b/apps/web/modules/survey/editor/page.tsx @@ -0,0 +1,135 @@ +import { authOptions } from "@/modules/auth/lib/authOptions"; +import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contacts"; +import { getSegments } from "@/modules/ee/contacts/segments/lib/segments"; +import { getIsContactsEnabled, getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; +import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; +import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; +import { getProjectLanguages } from "@/modules/survey/editor/lib/project"; +import { getUserEmail } from "@/modules/survey/editor/lib/user"; +import { getActionClasses } from "@/modules/survey/lib/action-class"; +import { getEnvironment } from "@/modules/survey/lib/environment"; +import { getMembershipRoleByUserIdOrganizationId } from "@/modules/survey/lib/membership"; +import { getProjectByEnvironmentId } from "@/modules/survey/lib/project"; +import { getResponseCountBySurveyId } from "@/modules/survey/lib/response"; +import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey"; +import { ErrorComponent } from "@/modules/ui/components/error-component"; +import { getTranslate } from "@/tolgee/server"; +import { getServerSession } from "next-auth"; +import { + DEFAULT_LOCALE, + IS_FORMBRICKS_CLOUD, + MAIL_FROM, + SURVEY_BG_COLORS, + UNSPLASH_ACCESS_KEY, +} from "@formbricks/lib/constants"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { SurveyEditor } from "./components/survey-editor"; +import { getUserLocale } from "./lib/user"; + +export const generateMetadata = async (props) => { + const params = await props.params; + const survey = await getSurvey(params.surveyId); + return { + title: survey?.name ? `${survey?.name} | Editor` : "Editor", + }; +}; + +export const SurveyEditorPage = async (props) => { + const searchParams = await props.searchParams; + const params = await props.params; + const t = await getTranslate(); + const [ + survey, + project, + environment, + actionClasses, + contactAttributeKeys, + responseCount, + session, + segments, + ] = await Promise.all([ + getSurvey(params.surveyId), + getProjectByEnvironmentId(params.environmentId), + getEnvironment(params.environmentId), + getActionClasses(params.environmentId), + getContactAttributeKeys(params.environmentId), + getResponseCountBySurveyId(params.surveyId), + getServerSession(authOptions), + getSegments(params.environmentId), + ]); + + if (!session) { + throw new Error(t("common.session_not_found")); + } + + if (!project) { + throw new Error(t("common.project_not_found")); + } + + const organizationBilling = await getOrganizationBilling(project.organizationId); + if (!organizationBilling) { + throw new Error(t("common.organization_not_found")); + } + + const membershipRole = await getMembershipRoleByUserIdOrganizationId( + session?.user.id, + project.organizationId + ); + const { isMember } = getAccessFlags(membershipRole); + + const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id); + + const { hasReadAccess } = getTeamPermissionFlags(projectPermission); + + const isSurveyCreationDeletionDisabled = isMember && hasReadAccess; + const locale = session.user.id ? await getUserLocale(session.user.id) : undefined; + + const isUserTargetingAllowed = await getIsContactsEnabled(); + const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan); + const isSurveyFollowUpsAllowed = await getSurveyFollowUpsPermission(organizationBilling.plan); + + const userEmail = await getUserEmail(session.user.id); + + const projectLanguages = await getProjectLanguages(project.id); + + if ( + !survey || + !environment || + !actionClasses || + !contactAttributeKeys || + !project || + !userEmail || + isSurveyCreationDeletionDisabled + ) { + return ; + } + + const isCxMode = searchParams.mode === "cx"; + + return ( + + ); +}; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/types/survey-follow-up.ts b/apps/web/modules/survey/editor/types/survey-follow-up.ts similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/types/survey-follow-up.ts rename to apps/web/modules/survey/editor/types/survey-follow-up.ts diff --git a/apps/web/modules/survey/editor/types/survey-trigger.ts b/apps/web/modules/survey/editor/types/survey-trigger.ts new file mode 100644 index 0000000000..08127f3077 --- /dev/null +++ b/apps/web/modules/survey/editor/types/survey-trigger.ts @@ -0,0 +1,8 @@ +export interface TriggerUpdate { + create?: Array<{ actionClassId: string }>; + deleteMany?: { + actionClassId: { + in: string[]; + }; + }; +} diff --git a/apps/web/modules/survey-follow-ups/components/follow-up-action-multi-email-input.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-action-multi-email-input.tsx similarity index 100% rename from apps/web/modules/survey-follow-ups/components/follow-up-action-multi-email-input.tsx rename to apps/web/modules/survey/follow-ups/components/follow-up-action-multi-email-input.tsx diff --git a/apps/web/modules/survey-follow-ups/components/follow-up-item.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx similarity index 98% rename from apps/web/modules/survey-follow-ups/components/follow-up-item.tsx rename to apps/web/modules/survey/follow-ups/components/follow-up-item.tsx index 4d26abd538..0b71fe1742 100644 --- a/apps/web/modules/survey-follow-ups/components/follow-up-item.tsx +++ b/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx @@ -1,6 +1,6 @@ "use client"; -import { FollowUpModal } from "@/modules/survey-follow-ups/components/follow-up-modal"; +import { FollowUpModal } from "@/modules/survey/follow-ups/components/follow-up-modal"; import { Badge } from "@/modules/ui/components/badge"; import { Button } from "@/modules/ui/components/button"; import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal"; diff --git a/apps/web/modules/survey-follow-ups/components/follow-up-modal.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-modal.tsx similarity index 98% rename from apps/web/modules/survey-follow-ups/components/follow-up-modal.tsx rename to apps/web/modules/survey/follow-ups/components/follow-up-modal.tsx index 00896b23e9..0cbd120a01 100644 --- a/apps/web/modules/survey-follow-ups/components/follow-up-modal.tsx +++ b/apps/web/modules/survey/follow-ups/components/follow-up-modal.tsx @@ -1,7 +1,11 @@ "use client"; -import { getSurveyFollowUpActionDefaultBody } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils"; -import FollowUpActionMultiEmailInput from "@/modules/survey-follow-ups/components/follow-up-action-multi-email-input"; +import FollowUpActionMultiEmailInput from "@/modules/survey/follow-ups/components/follow-up-action-multi-email-input"; +import { getSurveyFollowUpActionDefaultBody } from "@/modules/survey/editor/lib/utils"; +import { + TCreateSurveyFollowUpForm, + ZCreateSurveyFollowUpFormSchema, +} from "@/modules/survey/editor/types/survey-follow-up"; import { Button } from "@/modules/ui/components/button"; import { Checkbox } from "@/modules/ui/components/checkbox"; import { Editor } from "@/modules/ui/components/editor"; @@ -38,10 +42,6 @@ import { getQuestionIconMap } from "@formbricks/lib/utils/questions"; import { recallToHeadline } from "@formbricks/lib/utils/recall"; import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; -import { - TCreateSurveyFollowUpForm, - ZCreateSurveyFollowUpFormSchema, -} from "../../../app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/types/survey-follow-up"; interface AddFollowUpModalProps { localSurvey: TSurvey; @@ -588,9 +588,9 @@ export const FollowUpModal = ({
{ QUESTIONS_ICON_MAP[ - option.type === "openTextQuestion" - ? "openText" - : "contactInfo" + option.type === "openTextQuestion" + ? "openText" + : "contactInfo" ] }
diff --git a/apps/web/modules/survey-follow-ups/components/follow-ups-view.tsx b/apps/web/modules/survey/follow-ups/components/follow-ups-view.tsx similarity index 97% rename from apps/web/modules/survey-follow-ups/components/follow-ups-view.tsx rename to apps/web/modules/survey/follow-ups/components/follow-ups-view.tsx index 7f42bc0f8d..ebfaaa789d 100644 --- a/apps/web/modules/survey-follow-ups/components/follow-ups-view.tsx +++ b/apps/web/modules/survey/follow-ups/components/follow-ups-view.tsx @@ -1,7 +1,7 @@ "use client"; -import { FollowUpItem } from "@/modules/survey-follow-ups/components/follow-up-item"; -import { FollowUpModal } from "@/modules/survey-follow-ups/components/follow-up-modal"; +import { FollowUpItem } from "@/modules/survey/follow-ups/components/follow-up-item"; +import { FollowUpModal } from "@/modules/survey/follow-ups/components/follow-up-modal"; import { Button } from "@/modules/ui/components/button"; import { useTranslate } from "@tolgee/react"; import { LockIcon, MailIcon } from "lucide-react"; diff --git a/apps/web/modules/survey/follow-ups/lib/utils.ts b/apps/web/modules/survey/follow-ups/lib/utils.ts new file mode 100644 index 0000000000..1b1e463dac --- /dev/null +++ b/apps/web/modules/survey/follow-ups/lib/utils.ts @@ -0,0 +1,9 @@ +import { Organization } from "@prisma/client"; +import { IS_FORMBRICKS_CLOUD, PROJECT_FEATURE_KEYS } from "@formbricks/lib/constants"; + +export const getSurveyFollowUpsPermission = async ( + billingPlan: Organization["billing"]["plan"] +): Promise => { + if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE; + return true; +}; diff --git a/apps/web/modules/survey/lib/action-class.ts b/apps/web/modules/survey/lib/action-class.ts new file mode 100644 index 0000000000..489892b837 --- /dev/null +++ b/apps/web/modules/survey/lib/action-class.ts @@ -0,0 +1,34 @@ +import { ActionClass } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { z } from "zod"; +import { prisma } from "@formbricks/database"; +import { actionClassCache } from "@formbricks/lib/actionClass/cache"; +import { cache } from "@formbricks/lib/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { DatabaseError } from "@formbricks/types/errors"; + +export const getActionClasses = reactCache( + async (environmentId: string): Promise => + cache( + async () => { + validateInputs([environmentId, z.string().cuid2()]); + + try { + return await prisma.actionClass.findMany({ + where: { + environmentId: environmentId, + }, + orderBy: { + createdAt: "asc", + }, + }); + } catch (error) { + throw new DatabaseError(`Database error when fetching actions for environment ${environmentId}`); + } + }, + [`survey-lib-getActionClasses-${environmentId}`], + { + tags: [actionClassCache.tag.byEnvironmentId(environmentId)], + } + )() +); diff --git a/apps/web/modules/survey/lib/environment.ts b/apps/web/modules/survey/lib/environment.ts new file mode 100644 index 0000000000..a43e35a0a5 --- /dev/null +++ b/apps/web/modules/survey/lib/environment.ts @@ -0,0 +1,42 @@ +import { Environment, Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { z } from "zod"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { environmentCache } from "@formbricks/lib/environment/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { DatabaseError } from "@formbricks/types/errors"; + +export const getEnvironment = reactCache( + async (environmentId: string): Promise | null> => + cache( + async () => { + validateInputs([environmentId, z.string().cuid2()]); + + try { + const environment = await prisma.environment.findUnique({ + where: { + id: environmentId, + }, + select: { + id: true, + appSetupCompleted: true, + }, + }); + + return environment; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`survey-lib-getEnvironment-${environmentId}`], + { + tags: [environmentCache.tag.byId(environmentId)], + } + )() +); diff --git a/apps/web/modules/survey/lib/membership.ts b/apps/web/modules/survey/lib/membership.ts new file mode 100644 index 0000000000..2353e9e3e1 --- /dev/null +++ b/apps/web/modules/survey/lib/membership.ts @@ -0,0 +1,45 @@ +import { OrganizationRole, Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { z } from "zod"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { membershipCache } from "@formbricks/lib/membership/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { AuthorizationError, DatabaseError, UnknownError } from "@formbricks/types/errors"; + +export const getMembershipRoleByUserIdOrganizationId = reactCache( + async (userId: string, organizationId: string): Promise => + cache( + async () => { + validateInputs([userId, z.string()], [organizationId, z.string().cuid2()]); + + try { + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId, + organizationId, + }, + }, + }); + + if (!membership) { + throw new AuthorizationError("You are not a member of this organization"); + } + + return membership.role; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + + throw new UnknownError("Error while fetching membership"); + } + }, + [`survey-getMembershipRoleByUserIdOrganizationId-${userId}-${organizationId}`], + { + tags: [membershipCache.tag.byUserId(userId), membershipCache.tag.byOrganizationId(organizationId)], + } + )() +); diff --git a/apps/web/modules/survey/lib/organization.ts b/apps/web/modules/survey/lib/organization.ts new file mode 100644 index 0000000000..b345ff75c1 --- /dev/null +++ b/apps/web/modules/survey/lib/organization.ts @@ -0,0 +1,68 @@ +import { Organization, Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { organizationCache } from "@formbricks/lib/organization/cache"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; + +export const getOrganizationIdFromEnvironmentId = reactCache( + async (environmentId: string): Promise => + cache( + async () => { + const organization = await prisma.organization.findFirst({ + where: { + projects: { + some: { + environments: { + some: { id: environmentId }, + }, + }, + }, + }, + select: { + id: true, + }, + }); + + if (!organization) { + throw new ResourceNotFoundError("Organization", null); + } + + return organization.id; + }, + [`survey-lib-getOrganizationIdFromEnvironmentId-${environmentId}`], + { + tags: [organizationCache.tag.byEnvironmentId(environmentId)], + } + )() +); + +export const getOrganizationAIKeys = reactCache( + async (organizationId: string): Promise | null> => + cache( + async () => { + try { + const organization = await prisma.organization.findUnique({ + where: { + id: organizationId, + }, + select: { + isAIEnabled: true, + billing: true, + }, + }); + return organization; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`survey-lib-getOrganizationAIKeys-${organizationId}`], + { + tags: [organizationCache.tag.byId(organizationId)], + } + )() +); diff --git a/apps/web/modules/survey/lib/project.ts b/apps/web/modules/survey/lib/project.ts new file mode 100644 index 0000000000..c12ab8a498 --- /dev/null +++ b/apps/web/modules/survey/lib/project.ts @@ -0,0 +1,41 @@ +import "server-only"; +import { Project } from "@prisma/client"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { projectCache } from "@formbricks/lib/project/cache"; +import { DatabaseError } from "@formbricks/types/errors"; + +export const getProjectByEnvironmentId = reactCache( + async (environmentId: string): Promise => + cache( + async () => { + let projectPrisma; + + try { + projectPrisma = await prisma.project.findFirst({ + where: { + environments: { + some: { + id: environmentId, + }, + }, + }, + }); + + return projectPrisma; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } + }, + [`survey-lib-getProjectByEnvironmentId-${environmentId}`], + { + tags: [projectCache.tag.byEnvironmentId(environmentId)], + } + )() +); diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/user.ts b/apps/web/modules/survey/lib/response.ts similarity index 53% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/user.ts rename to apps/web/modules/survey/lib/response.ts index 46aab944cb..45f7af67fe 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/user.ts +++ b/apps/web/modules/survey/lib/response.ts @@ -2,21 +2,20 @@ import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; import { cache } from "@formbricks/lib/cache"; -import { userCache } from "@formbricks/lib/user/cache"; +import { responseCache } from "@formbricks/lib/response/cache"; import { DatabaseError } from "@formbricks/types/errors"; -export const getUserEmail = reactCache( - (userId: string): Promise => +export const getResponseCountBySurveyId = reactCache( + async (surveyId: string): Promise => cache( async () => { try { - const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } }); - - if (!user) { - return null; - } - - return user.email; + const responseCount = await prisma.response.count({ + where: { + surveyId, + }, + }); + return responseCount; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError(error.message); @@ -25,9 +24,9 @@ export const getUserEmail = reactCache( throw error; } }, - [`getUserEmail-${userId}`], + [`survey-editor-getResponseCountBySurveyId-${surveyId}`], { - tags: [userCache.tag.byId(userId)], + tags: [responseCache.tag.bySurveyId(surveyId)], } )() ); diff --git a/apps/web/modules/survey/lib/survey.ts b/apps/web/modules/survey/lib/survey.ts new file mode 100644 index 0000000000..ba35bc36bf --- /dev/null +++ b/apps/web/modules/survey/lib/survey.ts @@ -0,0 +1,143 @@ +import { transformPrismaSurvey } from "@/modules/survey/lib/utils"; +import { Organization, Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { organizationCache } from "@formbricks/lib/organization/cache"; +import { surveyCache } from "@formbricks/lib/survey/cache"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TSurvey } from "@formbricks/types/surveys/types"; + +export const selectSurvey = { + id: true, + createdAt: true, + updatedAt: true, + name: true, + type: true, + environmentId: true, + createdBy: true, + status: true, + welcomeCard: true, + questions: true, + endings: true, + hiddenFields: true, + variables: true, + displayOption: true, + recontactDays: true, + displayLimit: true, + autoClose: true, + runOnDate: true, + closeOnDate: true, + delay: true, + displayPercentage: true, + autoComplete: true, + isVerifyEmailEnabled: true, + isSingleResponsePerEmailEnabled: true, + redirectUrl: true, + projectOverwrites: true, + styling: true, + surveyClosedMessage: true, + singleUse: true, + pin: true, + resultShareKey: true, + showLanguageSwitch: true, + languages: { + select: { + default: true, + enabled: true, + language: { + select: { + id: true, + code: true, + alias: true, + createdAt: true, + updatedAt: true, + projectId: true, + }, + }, + }, + }, + triggers: { + select: { + actionClass: { + select: { + id: true, + createdAt: true, + updatedAt: true, + environmentId: true, + name: true, + description: true, + type: true, + key: true, + noCodeConfig: true, + }, + }, + }, + }, + segment: { + include: { + surveys: { + select: { + id: true, + }, + }, + }, + }, + followUps: true, +} satisfies Prisma.SurveySelect; + +export const getOrganizationBilling = reactCache( + async (organizationId: string): Promise => + cache( + async () => { + try { + const organization = await prisma.organization.findFirst({ + where: { + id: organizationId, + }, + select: { + billing: true, + }, + }); + + if (!organization) { + throw new ResourceNotFoundError("Organization", null); + } + + return organization.billing; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`survey-lib-getOrganizationBilling-${organizationId}`], + { + tags: [organizationCache.tag.byId(organizationId)], + } + )() +); + +export const getSurvey = reactCache( + async (surveyId: string): Promise => + cache( + async () => { + const survey = await prisma.survey.findUnique({ + where: { id: surveyId }, + select: selectSurvey, + }); + + if (!survey) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + return transformPrismaSurvey(survey); + }, + [`survey-editor-getSurvey-${surveyId}`], + { + tags: [surveyCache.tag.byId(surveyId)], + } + )() +); diff --git a/apps/web/modules/survey/lib/utils.ts b/apps/web/modules/survey/lib/utils.ts new file mode 100644 index 0000000000..f1dceeb11b --- /dev/null +++ b/apps/web/modules/survey/lib/utils.ts @@ -0,0 +1,120 @@ +import "server-only"; +import { Prisma } from "@prisma/client"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { llmModel } from "@formbricks/lib/aiModels"; +import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; +import { TSegment } from "@formbricks/types/segment"; +import { + TSurvey, + TSurveyFilterCriteria, + TSurveyQuestion, + TSurveyQuestions, +} from "@formbricks/types/surveys/types"; + +export const getInsightsEnabled = async (question: TSurveyQuestion): Promise => { + try { + const { object } = await generateObject({ + model: llmModel, + schema: z.object({ + insightsEnabled: z.boolean(), + }), + prompt: `We extract insights (e.g. feature requests, complaints, other) from survey questions. Can we find them in this question?: ${question.headline.default}`, + experimental_telemetry: { isEnabled: true }, + }); + + return object.insightsEnabled; + } catch (error) { + throw error; + } +}; + +export const transformPrismaSurvey = ( + surveyPrisma: any +): T => { + let segment: TSegment | null = null; + + if (surveyPrisma.segment) { + segment = { + ...surveyPrisma.segment, + surveys: surveyPrisma.segment.surveys.map((survey) => survey.id), + }; + } + + const transformedSurvey = { + ...surveyPrisma, + displayPercentage: Number(surveyPrisma.displayPercentage) || null, + segment, + } as T; + + return transformedSurvey; +}; + +export const buildWhereClause = (filterCriteria?: TSurveyFilterCriteria) => { + const whereClause: Prisma.SurveyWhereInput["AND"] = []; + + // for name + if (filterCriteria?.name) { + whereClause.push({ name: { contains: filterCriteria.name, mode: "insensitive" } }); + } + + // for status + if (filterCriteria?.status && filterCriteria?.status?.length) { + whereClause.push({ status: { in: filterCriteria.status } }); + } + + // for type + if (filterCriteria?.type && filterCriteria?.type?.length) { + whereClause.push({ type: { in: filterCriteria.type } }); + } + + // for createdBy + if (filterCriteria?.createdBy?.value && filterCriteria?.createdBy?.value?.length) { + if (filterCriteria.createdBy.value.length === 1) { + if (filterCriteria.createdBy.value[0] === "you") { + whereClause.push({ createdBy: filterCriteria.createdBy.userId }); + } + if (filterCriteria.createdBy.value[0] === "others") { + whereClause.push({ + OR: [ + { + createdBy: { + not: filterCriteria.createdBy.userId, + }, + }, + { + createdBy: null, + }, + ], + }); + } + } + } + + return { AND: whereClause }; +}; + +export const buildOrderByClause = ( + sortBy?: TSurveyFilterCriteria["sortBy"] +): Prisma.SurveyOrderByWithRelationInput[] | undefined => { + const orderMapping: { [key: string]: Prisma.SurveyOrderByWithRelationInput } = { + name: { name: "asc" }, + createdAt: { createdAt: "desc" }, + updatedAt: { updatedAt: "desc" }, + }; + + return sortBy ? [orderMapping[sortBy] || { updatedAt: "desc" }] : undefined; +}; + +export const anySurveyHasFilters = (surveys: TSurvey[]): boolean => { + return surveys.some((survey) => { + if ("segment" in survey && survey.segment) { + return survey.segment.filters && survey.segment.filters.length > 0; + } + return false; + }); +}; + +export const doesSurveyHasOpenTextQuestion = (questions: TSurveyQuestions): boolean => { + return questions.some((question) => question.type === "openText"); +}; diff --git a/apps/web/app/s/[surveyId]/actions.ts b/apps/web/modules/survey/link/actions.ts similarity index 57% rename from apps/web/app/s/[surveyId]/actions.ts rename to apps/web/modules/survey/link/actions.ts index b6d1a21a86..8c694c0d0a 100644 --- a/apps/web/app/s/[surveyId]/actions.ts +++ b/apps/web/modules/survey/link/actions.ts @@ -4,11 +4,10 @@ import { actionClient } from "@/lib/utils/action-client"; import { getOrganizationIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization"; import { sendLinkSurveyToVerifiedEmail } from "@/modules/email"; +import { getSurvey } from "@/modules/survey/lib/survey"; +import { isSurveyResponsePresent } from "@/modules/survey/link/lib/response"; +import { getSurveyPin } from "@/modules/survey/link/lib/survey"; import { z } from "zod"; -import { verifyTokenForLinkSurvey } from "@formbricks/lib/jwt"; -import { getIfResponseWithSurveyIdAndEmailExist } from "@formbricks/lib/response/service"; -import { getSurvey } from "@formbricks/lib/survey/service"; -import { ZId } from "@formbricks/types/common"; import { ZLinkSurveyEmailData } from "@formbricks/types/email"; import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; @@ -22,27 +21,22 @@ export const sendLinkSurveyEmailAction = actionClient return { success: true }; }); -const ZVerifyTokenAction = z.object({ - surveyId: ZId, - token: z.string(), -}); - -export const verifyTokenAction = actionClient.schema(ZVerifyTokenAction).action(async ({ parsedInput }) => { - return await verifyTokenForLinkSurvey(parsedInput.token, parsedInput.surveyId); -}); - const ZValidateSurveyPinAction = z.object({ - surveyId: ZId, + surveyId: z.string().cuid2(), pin: z.string(), }); export const validateSurveyPinAction = actionClient .schema(ZValidateSurveyPinAction) .action(async ({ parsedInput }) => { - const survey = await getSurvey(parsedInput.surveyId); - if (!survey) throw new ResourceNotFoundError("Survey", parsedInput.surveyId); + const surveyPin = await getSurveyPin(parsedInput.surveyId); + if (!surveyPin) { + throw new ResourceNotFoundError("Survey", parsedInput.surveyId); + } - const originalPin = survey.pin?.toString(); + const originalPin = surveyPin.toString(); + + const survey = await getSurvey(parsedInput.surveyId); if (!originalPin) return { survey }; if (originalPin !== parsedInput.pin) { @@ -52,13 +46,13 @@ export const validateSurveyPinAction = actionClient return { survey }; }); -const ZGetIfResponseWithSurveyIdAndEmailExistAction = z.object({ - surveyId: ZId, - email: z.string(), +const ZIsSurveyResponsePresentAction = z.object({ + surveyId: z.string().cuid2(), + email: z.string().email(), }); -export const getIfResponseWithSurveyIdAndEmailExistAction = actionClient - .schema(ZGetIfResponseWithSurveyIdAndEmailExistAction) +export const isSurveyResponsePresentAction = actionClient + .schema(ZIsSurveyResponsePresentAction) .action(async ({ parsedInput }) => { - return await getIfResponseWithSurveyIdAndEmailExist(parsedInput.surveyId, parsedInput.email); + return await isSurveyResponsePresent(parsedInput.surveyId, parsedInput.email); }); diff --git a/apps/web/app/s/[surveyId]/components/legal-footer.tsx b/apps/web/modules/survey/link/components/legal-footer.tsx similarity index 100% rename from apps/web/app/s/[surveyId]/components/legal-footer.tsx rename to apps/web/modules/survey/link/components/legal-footer.tsx diff --git a/apps/web/app/s/[surveyId]/components/link-survey-wrapper.tsx b/apps/web/modules/survey/link/components/link-survey-wrapper.tsx similarity index 71% rename from apps/web/app/s/[surveyId]/components/link-survey-wrapper.tsx rename to apps/web/modules/survey/link/components/link-survey-wrapper.tsx index 53d8eb2727..18acc02a39 100644 --- a/apps/web/app/s/[surveyId]/components/link-survey-wrapper.tsx +++ b/apps/web/modules/survey/link/components/link-survey-wrapper.tsx @@ -1,17 +1,20 @@ -import { LegalFooter } from "@/app/s/[surveyId]/components/legal-footer"; -import { SurveyLoadingAnimation } from "@/app/s/[surveyId]/components/survey-loading-animation"; +import { LegalFooter } from "@/modules/survey/link/components/legal-footer"; +import { SurveyLoadingAnimation } from "@/modules/survey/link/components/survey-loading-animation"; import { ClientLogo } from "@/modules/ui/components/client-logo"; import { MediaBackground } from "@/modules/ui/components/media-background"; import { ResetProgressButton } from "@/modules/ui/components/reset-progress-button"; +import { Project, SurveyType } from "@prisma/client"; import { type JSX, useState } from "react"; import { cn } from "@formbricks/lib/cn"; -import { TProject, TProjectStyling } from "@formbricks/types/project"; -import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types"; +import { TProjectStyling } from "@formbricks/types/project"; +import { TSurveyStyling } from "@formbricks/types/surveys/types"; interface LinkSurveyWrapperProps { children: JSX.Element; - project: TProject; - survey: TSurvey; + project: Pick; + isWelcomeCardEnabled: boolean; + surveyId: string; + surveyType: SurveyType; isPreview: boolean; isEmbed: boolean; determineStyling: () => TSurveyStyling | TProjectStyling; @@ -26,7 +29,9 @@ interface LinkSurveyWrapperProps { export const LinkSurveyWrapper = ({ children, project, - survey, + isWelcomeCardEnabled, + surveyType, + surveyId, isPreview, isEmbed, determineStyling, @@ -54,7 +59,10 @@ export const LinkSurveyWrapper = ({ styling.cardArrangement?.linkSurveys === "straight" && "pt-6", styling.cardArrangement?.linkSurveys === "casual" && "px-6 py-10" )}> - + {children}
); @@ -62,13 +70,16 @@ export const LinkSurveyWrapper = ({ return (
- +
- {!styling.isLogoHidden && project.logo?.url && } + {!styling.isLogoHidden && project.logo?.url && }
{isPreview && (
@@ -85,7 +96,7 @@ export const LinkSurveyWrapper = ({ IMPRINT_URL={IMPRINT_URL} PRIVACY_URL={PRIVACY_URL} IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD} - surveyUrl={webAppUrl + "/s/" + survey.id} + surveyUrl={webAppUrl + "/s/" + surveyId} />
); diff --git a/apps/web/app/s/[surveyId]/components/link-survey.tsx b/apps/web/modules/survey/link/components/link-survey.tsx similarity index 93% rename from apps/web/app/s/[surveyId]/components/link-survey.tsx rename to apps/web/modules/survey/link/components/link-survey.tsx index a5304bdfe1..1b347f1081 100644 --- a/apps/web/app/s/[surveyId]/components/link-survey.tsx +++ b/apps/web/modules/survey/link/components/link-survey.tsx @@ -1,10 +1,11 @@ "use client"; -import { LinkSurveyWrapper } from "@/app/s/[surveyId]/components/link-survey-wrapper"; -import { SurveyLinkUsed } from "@/app/s/[surveyId]/components/survey-link-used"; -import { VerifyEmail } from "@/app/s/[surveyId]/components/verify-email"; -import { getPrefillValue } from "@/app/s/[surveyId]/lib/prefilling"; +import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper"; +import { SurveyLinkUsed } from "@/modules/survey/link/components/survey-link-used"; +import { VerifyEmail } from "@/modules/survey/link/components/verify-email"; +import { getPrefillValue } from "@/modules/survey/link/lib/utils"; import { SurveyInline } from "@/modules/ui/components/survey"; +import { Project, Response } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; @@ -12,13 +13,7 @@ import { FormbricksAPI } from "@formbricks/api"; import { ResponseQueue } from "@formbricks/lib/responseQueue"; import { SurveyState } from "@formbricks/lib/surveyState"; import { TJsFileUploadParams } from "@formbricks/types/js"; -import { TProject } from "@formbricks/types/project"; -import { - TResponse, - TResponseData, - TResponseHiddenFieldValue, - TResponseUpdate, -} from "@formbricks/types/responses"; +import { TResponseData, TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses"; import { TUploadFileConfig } from "@formbricks/types/storage"; import { TSurvey } from "@formbricks/types/surveys/types"; @@ -29,10 +24,10 @@ let setResponseData = (_: TResponseData) => {}; interface LinkSurveyProps { survey: TSurvey; - project: TProject; + project: Pick; emailVerificationStatus?: string; singleUseId?: string; - singleUseResponse?: TResponse; + singleUseResponse?: Pick; webAppUrl: string; responseCount?: number; verifiedEmail?: string; @@ -203,8 +198,10 @@ export const LinkSurvey = ({ return ( ; emailVerificationStatus?: string; singleUseId?: string; - singleUseResponse?: TResponse; + singleUseResponse?: Pick; webAppUrl: string; IMPRINT_URL?: string; PRIVACY_URL?: string; diff --git a/apps/web/app/s/[surveyId]/components/survey-inactive.tsx b/apps/web/modules/survey/link/components/survey-inactive.tsx similarity index 100% rename from apps/web/app/s/[surveyId]/components/survey-inactive.tsx rename to apps/web/modules/survey/link/components/survey-inactive.tsx diff --git a/apps/web/app/s/[surveyId]/components/survey-link-used.tsx b/apps/web/modules/survey/link/components/survey-link-used.tsx similarity index 100% rename from apps/web/app/s/[surveyId]/components/survey-link-used.tsx rename to apps/web/modules/survey/link/components/survey-link-used.tsx diff --git a/apps/web/app/s/[surveyId]/components/survey-loading-animation.tsx b/apps/web/modules/survey/link/components/survey-loading-animation.tsx similarity index 96% rename from apps/web/app/s/[surveyId]/components/survey-loading-animation.tsx rename to apps/web/modules/survey/link/components/survey-loading-animation.tsx index 8ee9c5f686..9a8371bbb7 100644 --- a/apps/web/app/s/[surveyId]/components/survey-loading-animation.tsx +++ b/apps/web/modules/survey/link/components/survey-loading-animation.tsx @@ -3,16 +3,15 @@ import { LoadingSpinner } from "@/modules/ui/components/loading-spinner"; import Image from "next/image"; import { useCallback, useEffect, useState } from "react"; import { cn } from "@formbricks/lib/cn"; -import { TSurvey } from "@formbricks/types/surveys/types"; interface SurveyLoadingAnimationProps { - survey: TSurvey; + isWelcomeCardEnabled: boolean; isBackgroundLoaded?: boolean; isBrandingEnabled: boolean; } export const SurveyLoadingAnimation = ({ - survey, + isWelcomeCardEnabled, isBackgroundLoaded = true, isBrandingEnabled, }: SurveyLoadingAnimationProps) => { @@ -21,7 +20,7 @@ export const SurveyLoadingAnimation = ({ const [isMediaLoaded, setIsMediaLoaded] = useState(false); // Tracks if all media are fully loaded const [isSurveyPackageLoaded, setIsSurveyPackageLoaded] = useState(false); // Tracks if the survey package has been loaded into the DOM const isReadyToTransition = isMediaLoaded && minTimePassed && isBackgroundLoaded; - const cardId = survey.welcomeCard.enabled ? `questionCard--1` : `questionCard-0`; + const cardId = isWelcomeCardEnabled ? `questionCard--1` : `questionCard-0`; // Function to check if all media elements (images and iframes) within the survey card are loaded const checkMediaLoaded = useCallback(() => { diff --git a/apps/web/app/s/[surveyId]/components/verify-email.tsx b/apps/web/modules/survey/link/components/verify-email.tsx similarity index 97% rename from apps/web/app/s/[surveyId]/components/verify-email.tsx rename to apps/web/modules/survey/link/components/verify-email.tsx index 2a2c4ba211..e360a61aa2 100644 --- a/apps/web/app/s/[surveyId]/components/verify-email.tsx +++ b/apps/web/modules/survey/link/components/verify-email.tsx @@ -1,10 +1,10 @@ "use client"; -import { - getIfResponseWithSurveyIdAndEmailExistAction, - sendLinkSurveyEmailAction, -} from "@/app/s/[surveyId]/actions"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { + isSurveyResponsePresentAction, + sendLinkSurveyEmailAction, +} from "@/modules/survey/link/actions"; import { Button } from "@/modules/ui/components/button"; import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; import { Input } from "@/modules/ui/components/input"; @@ -61,7 +61,7 @@ export const VerifyEmail = ({ const submitEmail = async (emailInput: TVerifyEmailInput) => { const email = emailInput.email.toLowerCase(); if (localSurvey.isSingleResponsePerEmailEnabled) { - const actionResult = await getIfResponseWithSurveyIdAndEmailExistAction({ + const actionResult = await isSurveyResponsePresentAction({ surveyId: localSurvey.id, email, }); diff --git a/apps/web/modules/survey/link/layout.tsx b/apps/web/modules/survey/link/layout.tsx new file mode 100644 index 0000000000..06bf60fae3 --- /dev/null +++ b/apps/web/modules/survey/link/layout.tsx @@ -0,0 +1,13 @@ +import { Viewport } from "next"; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1.0, + maximumScale: 1.0, + userScalable: false, + viewportFit: "contain", +}; + +export const LinkSurveyLayout = ({ children }) => { + return
{children}
; +}; diff --git a/apps/web/app/s/[surveyId]/lib/footerlogo.svg b/apps/web/modules/survey/link/lib/footerlogo.svg similarity index 100% rename from apps/web/app/s/[surveyId]/lib/footerlogo.svg rename to apps/web/modules/survey/link/lib/footerlogo.svg diff --git a/apps/web/app/s/[surveyId]/lib/helpers.ts b/apps/web/modules/survey/link/lib/helper.ts similarity index 96% rename from apps/web/app/s/[surveyId]/lib/helpers.ts rename to apps/web/modules/survey/link/lib/helper.ts index 37e0cd411c..055e709b1f 100644 --- a/apps/web/app/s/[surveyId]/lib/helpers.ts +++ b/apps/web/modules/survey/link/lib/helper.ts @@ -1,3 +1,4 @@ +import "server-only"; import { verifyTokenForLinkSurvey } from "@formbricks/lib/jwt"; interface emailVerificationDetails { diff --git a/apps/web/modules/survey/link/lib/project.ts b/apps/web/modules/survey/link/lib/project.ts new file mode 100644 index 0000000000..00237b4729 --- /dev/null +++ b/apps/web/modules/survey/link/lib/project.ts @@ -0,0 +1,49 @@ +import "server-only"; +import { Prisma, Project } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { projectCache } from "@formbricks/lib/project/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { ZId } from "@formbricks/types/common"; +import { DatabaseError } from "@formbricks/types/errors"; + +export const getProjectByEnvironmentId = reactCache( + async (environmentId: string): Promise | null> => + cache( + async () => { + validateInputs([environmentId, ZId]); + + let projectPrisma; + + try { + projectPrisma = await prisma.project.findFirst({ + where: { + environments: { + some: { + id: environmentId, + }, + }, + }, + select: { + styling: true, + logo: true, + linkSurveyBranding: true, + }, + }); + + return projectPrisma; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } + }, + [`survey-link-surveys-getProjectByEnvironmentId-${environmentId}`], + { + tags: [projectCache.tag.byEnvironmentId(environmentId)], + } + )() +); diff --git a/apps/web/modules/survey/link/lib/response.ts b/apps/web/modules/survey/link/lib/response.ts new file mode 100644 index 0000000000..b187466184 --- /dev/null +++ b/apps/web/modules/survey/link/lib/response.ts @@ -0,0 +1,71 @@ +import "server-only"; +import { Prisma, Response } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { responseCache } from "@formbricks/lib/response/cache"; +import { DatabaseError } from "@formbricks/types/errors"; + +export const isSurveyResponsePresent = reactCache( + async (surveyId: string, email: string): Promise => + cache( + async () => { + try { + const response = await prisma.response.findFirst({ + where: { + surveyId, + data: { + path: ["verifiedEmail"], + equals: email, + }, + }, + select: { id: true }, + }); + + return !!response; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`link-surveys-isSurveyResponsePresent-${surveyId}-${email}`], + { + tags: [responseCache.tag.bySurveyId(surveyId)], + } + )() +); + +export const getResponseBySingleUseId = reactCache( + async (surveyId: string, singleUseId: string): Promise | null> => + cache( + async () => { + try { + const response = await prisma.response.findFirst({ + where: { + surveyId, + singleUseId, + }, + select: { + id: true, + finished: true, + }, + }); + + return response; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`link-surveys-getResponseBySingleUseId-${surveyId}-${singleUseId}`], + { + tags: [responseCache.tag.bySingleUseId(surveyId, singleUseId)], + } + )() +); diff --git a/apps/web/modules/survey/link/lib/survey.ts b/apps/web/modules/survey/link/lib/survey.ts new file mode 100644 index 0000000000..32eaafd232 --- /dev/null +++ b/apps/web/modules/survey/link/lib/survey.ts @@ -0,0 +1,71 @@ +import "server-only"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { surveyCache } from "@formbricks/lib/survey/cache"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; + +export const getSurveyMetadata = reactCache(async (surveyId: string) => + cache( + async () => { + try { + const survey = await prisma.survey.findUnique({ + where: { + id: surveyId, + }, + select: { + id: true, + type: true, + status: true, + environmentId: true, + name: true, + styling: true, + }, + }); + + if (!survey) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + return survey; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } + }, + + [`link-survey-getSurveyMetadata-${surveyId}`], + { + tags: [surveyCache.tag.byId(surveyId)], + } + )() +); + +export const getSurveyPin = reactCache(async (surveyId: string) => + cache( + async () => { + const survey = await prisma.survey.findUnique({ + where: { + id: surveyId, + }, + select: { + pin: true, + }, + }); + + if (!survey) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + return survey.pin; + }, + [`link-survey-getSurveyPin-${surveyId}`], + { + tags: [surveyCache.tag.byId(surveyId)], + } + )() +); diff --git a/apps/web/app/s/[surveyId]/lib/prefilling.ts b/apps/web/modules/survey/link/lib/utils.ts similarity index 97% rename from apps/web/app/s/[surveyId]/lib/prefilling.ts rename to apps/web/modules/survey/link/lib/utils.ts index 79cc718867..d801ae910a 100644 --- a/apps/web/app/s/[surveyId]/lib/prefilling.ts +++ b/apps/web/modules/survey/link/lib/utils.ts @@ -30,7 +30,7 @@ export const getPrefillValue = ( return Object.keys(prefillAnswer).length > 0 ? prefillAnswer : undefined; }; -export const checkValidity = (question: TSurveyQuestion, answer: string, language: string): boolean => { +const checkValidity = (question: TSurveyQuestion, answer: string, language: string): boolean => { if (question.required && (!answer || answer === "")) return false; try { switch (question.type) { @@ -100,7 +100,7 @@ export const checkValidity = (question: TSurveyQuestion, answer: string, languag } }; -export const transformAnswer = ( +const transformAnswer = ( question: TSurveyQuestion, answer: string, language: string diff --git a/apps/web/modules/survey/link/loading.tsx b/apps/web/modules/survey/link/loading.tsx new file mode 100644 index 0000000000..c57df93712 --- /dev/null +++ b/apps/web/modules/survey/link/loading.tsx @@ -0,0 +1,11 @@ +"use client"; +export const LinkSurveyLoading = () => { + return ( +
+
+
+
+
+
+ ); +}; diff --git a/apps/web/app/s/[surveyId]/metadata.ts b/apps/web/modules/survey/link/metadata.ts similarity index 79% rename from apps/web/app/s/[surveyId]/metadata.ts rename to apps/web/modules/survey/link/metadata.ts index b3eb6606c3..7517d88bd5 100644 --- a/apps/web/app/s/[surveyId]/metadata.ts +++ b/apps/web/modules/survey/link/metadata.ts @@ -1,23 +1,16 @@ +import { getSurveyMetadata } from "@/modules/survey/link/lib/survey"; import { Metadata } from "next"; import { notFound } from "next/navigation"; import { WEBAPP_URL } from "@formbricks/lib/constants"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants"; -import { getSurvey } from "@formbricks/lib/survey/service"; export const getMetadataForLinkSurvey = async (surveyId: string): Promise => { - const survey = await getSurvey(surveyId); + const survey = await getSurveyMetadata(surveyId); if (!survey || survey.type !== "link" || survey.status === "draft") { notFound(); } - const project = await getProjectByEnvironmentId(survey.environmentId); - - if (!project) { - throw new Error("Project not found"); - } - const brandColor = getBrandColorForURL(survey.styling?.brandColor?.light ?? COLOR_DEFAULTS.brandColor); const surveyName = getNameForURL(survey.name); diff --git a/apps/web/modules/survey/link/not-found.tsx b/apps/web/modules/survey/link/not-found.tsx new file mode 100644 index 0000000000..c35983b89b --- /dev/null +++ b/apps/web/modules/survey/link/not-found.tsx @@ -0,0 +1,27 @@ +import { Button } from "@/modules/ui/components/button"; +import { HelpCircleIcon } from "lucide-react"; +import { StaticImport } from "next/dist/shared/lib/get-img-props"; +import Image from "next/image"; +import Link from "next/link"; +import footerLogo from "./lib/footerlogo.svg"; + +export const LinkSurveyNotFound = () => { + return ( +
+
+
+ , +

Survey not found.

+

There is no survey with this ID.

+ +
+
+ + Brand logo + +
+
+ ); +}; diff --git a/apps/web/modules/survey/link/page.tsx b/apps/web/modules/survey/link/page.tsx new file mode 100644 index 0000000000..087b9b9fb7 --- /dev/null +++ b/apps/web/modules/survey/link/page.tsx @@ -0,0 +1,190 @@ +import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys"; +import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; +import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization"; +import { getResponseCountBySurveyId } from "@/modules/survey/lib/response"; +import { getOrganizationBilling } from "@/modules/survey/lib/survey"; +import { getSurvey } from "@/modules/survey/lib/survey"; +import { LinkSurvey } from "@/modules/survey/link/components/link-survey"; +import { PinScreen } from "@/modules/survey/link/components/pin-screen"; +import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive"; +import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper"; +import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project"; +import { getResponseBySingleUseId } from "@/modules/survey/link/lib/response"; +import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata"; +import { Response } from "@prisma/client"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@formbricks/lib/constants"; +import { findMatchingLocale } from "@formbricks/lib/utils/locale"; +import { ZId } from "@formbricks/types/common"; + +interface LinkSurveyPageProps { + params: Promise<{ + surveyId: string; + }>; + searchParams: Promise<{ + suId?: string; + verify?: string; + lang?: string; + embed?: string; + preview?: string; + }>; +} + +export const generateMetadata = async (props: LinkSurveyPageProps): Promise => { + const params = await props.params; + const validId = ZId.safeParse(params.surveyId); + if (!validId.success) { + notFound(); + } + + return getMetadataForLinkSurvey(params.surveyId); +}; + +export const LinkSurveyPage = async (props: LinkSurveyPageProps) => { + const searchParams = await props.searchParams; + const params = await props.params; + const validId = ZId.safeParse(params.surveyId); + if (!validId.success) { + notFound(); + } + const isPreview = searchParams.preview === "true"; + const survey = await getSurvey(params.surveyId); + const locale = await findMatchingLocale(); + const suId = searchParams.suId; + const langParam = searchParams.lang; //can either be language code or alias + const isSingleUseSurvey = survey?.singleUse?.enabled; + const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted; + const isEmbed = searchParams.embed === "true"; + if (!survey || survey.type !== "link" || survey.status === "draft") { + notFound(); + } + + const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId); + const organizationBilling = await getOrganizationBilling(organizationId); + if (!organizationBilling) { + throw new Error("Organization not found"); + } + const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan); + + if (survey.status !== "inProgress" && !isPreview) { + return ( + + ); + } + + let singleUseId: string | undefined = undefined; + if (isSingleUseSurvey) { + // check if the single use id is present for single use surveys + if (!suId) { + return ; + } + + // if encryption is enabled, validate the single use id + let validatedSingleUseId: string | undefined = undefined; + if (isSingleUseSurveyEncrypted) { + validatedSingleUseId = validateSurveySingleUseId(suId); + if (!validatedSingleUseId) { + return ; + } + } + // if encryption is disabled, use the suId as is + singleUseId = validatedSingleUseId ?? suId; + } + + let singleUseResponse: Pick | undefined = undefined; + if (isSingleUseSurvey) { + try { + singleUseResponse = singleUseId + ? ((await getResponseBySingleUseId(survey.id, singleUseId)) ?? undefined) + : undefined; + } catch (error) { + singleUseResponse = undefined; + } + } + + // verify email: Check if the survey requires email verification + let emailVerificationStatus = ""; + let verifiedEmail: string | undefined = undefined; + + if (survey.isVerifyEmailEnabled) { + const token = searchParams.verify; + + if (token) { + const emailVerificationDetails = await getEmailVerificationDetails(survey.id, token); + emailVerificationStatus = emailVerificationDetails.status; + verifiedEmail = emailVerificationDetails.email; + } + } + + // get project and person + const project = await getProjectByEnvironmentId(survey.environmentId); + if (!project) { + throw new Error("Project not found"); + } + + const getLanguageCode = (): string => { + if (!langParam || !isMultiLanguageAllowed) return "default"; + else { + const selectedLanguage = survey.languages.find((surveyLanguage) => { + return ( + surveyLanguage.language.code === langParam.toLowerCase() || + surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase() + ); + }); + if (selectedLanguage?.default || !selectedLanguage?.enabled) { + return "default"; + } + return selectedLanguage.language.code; + } + }; + + const languageCode = getLanguageCode(); + + const isSurveyPinProtected = Boolean(survey.pin); + const responseCount = await getResponseCountBySurveyId(survey.id); + + if (isSurveyPinProtected) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts b/apps/web/modules/survey/list/actions.ts similarity index 84% rename from apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts rename to apps/web/modules/survey/list/actions.ts index 7d63e7939c..3e4a4df55e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts +++ b/apps/web/modules/survey/list/actions.ts @@ -1,6 +1,5 @@ "use server"; -import { getSurvey, getSurveys } from "@/app/(app)/environments/[environmentId]/surveys/lib/surveys"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { @@ -9,17 +8,21 @@ import { getProjectIdFromEnvironmentId, getProjectIdFromSurveyId, } from "@/lib/utils/helper"; -import { getEnvironment } from "@/lib/utils/services"; +import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment"; +import { getUserProjects } from "@/modules/survey/list/lib/project"; +import { + copySurveyToOtherEnvironment, + deleteSurvey, + getSurvey, + getSurveys, +} from "@/modules/survey/list/lib/survey"; import { z } from "zod"; -import { getUserProjects } from "@formbricks/lib/project/service"; -import { copySurveyToOtherEnvironment, deleteSurvey } from "@formbricks/lib/survey/service"; import { generateSurveySingleUseId } from "@formbricks/lib/utils/singleUseSurveys"; -import { ZId } from "@formbricks/types/common"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types"; const ZGetSurveyAction = z.object({ - surveyId: ZId, + surveyId: z.string().cuid2(), }); export const getSurveyAction = authenticatedActionClient @@ -45,21 +48,21 @@ export const getSurveyAction = authenticatedActionClient }); const ZCopySurveyToOtherEnvironmentAction = z.object({ - environmentId: ZId, - surveyId: ZId, - targetEnvironmentId: ZId, + environmentId: z.string().cuid2(), + surveyId: z.string().cuid2(), + targetEnvironmentId: z.string().cuid2(), }); export const copySurveyToOtherEnvironmentAction = authenticatedActionClient .schema(ZCopySurveyToOtherEnvironmentAction) .action(async ({ ctx, parsedInput }) => { - const sourceEnvironment = await getEnvironment(parsedInput.environmentId); - const targetEnvironment = await getEnvironment(parsedInput.targetEnvironmentId); + const sourceEnvironmentProjectId = await getProjectIdIfEnvironmentExists(parsedInput.environmentId); + const targetEnvironmentProjectId = await getProjectIdIfEnvironmentExists(parsedInput.targetEnvironmentId); - if (!sourceEnvironment || !targetEnvironment) { + if (!sourceEnvironmentProjectId || !targetEnvironmentProjectId) { throw new ResourceNotFoundError( "Environment", - sourceEnvironment ? parsedInput.targetEnvironmentId : parsedInput.environmentId + sourceEnvironmentProjectId ? parsedInput.targetEnvironmentId : parsedInput.environmentId ); } @@ -74,7 +77,7 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient { type: "projectTeam", minPermission: "readWrite", - projectId: sourceEnvironment.projectId, + projectId: sourceEnvironmentProjectId, }, ], }); @@ -90,7 +93,7 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient { type: "projectTeam", minPermission: "readWrite", - projectId: targetEnvironment.projectId, + projectId: targetEnvironmentProjectId, }, ], }); @@ -104,7 +107,7 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient }); const ZGetProjectsByEnvironmentIdAction = z.object({ - environmentId: ZId, + environmentId: z.string().cuid2(), }); export const getProjectsByEnvironmentIdAction = authenticatedActionClient @@ -131,7 +134,7 @@ export const getProjectsByEnvironmentIdAction = authenticatedActionClient }); const ZDeleteSurveyAction = z.object({ - surveyId: ZId, + surveyId: z.string().cuid2(), }); export const deleteSurveyAction = authenticatedActionClient @@ -157,7 +160,7 @@ export const deleteSurveyAction = authenticatedActionClient }); const ZGenerateSingleUseIdAction = z.object({ - surveyId: ZId, + surveyId: z.string().cuid2(), isEncrypted: z.boolean(), }); @@ -184,7 +187,7 @@ export const generateSingleUseIdAction = authenticatedActionClient }); const ZGetSurveysAction = z.object({ - environmentId: ZId, + environmentId: z.string().cuid2(), limit: z.number().optional(), offset: z.number().optional(), filterCriteria: ZSurveyFilterCriteria.optional(), diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/CopySurveyForm.tsx b/apps/web/modules/survey/list/components/copy-survey-form.tsx similarity index 92% rename from apps/web/app/(app)/environments/[environmentId]/surveys/components/CopySurveyForm.tsx rename to apps/web/modules/survey/list/components/copy-survey-form.tsx index 2877073c1e..4842c71522 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/CopySurveyForm.tsx +++ b/apps/web/modules/survey/list/components/copy-survey-form.tsx @@ -1,11 +1,8 @@ "use client"; -import { copySurveyToOtherEnvironmentAction } from "@/app/(app)/environments/[environmentId]/surveys/actions"; -import { - TSurvey, - TSurveyCopyFormData, - ZSurveyCopyFormValidation, -} from "@/app/(app)/environments/[environmentId]/surveys/types/surveys"; +import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions"; +import { TUserProject } from "@/modules/survey/list/types/projects"; +import { TSurvey, TSurveyCopyFormData, ZSurveyCopyFormValidation } from "@/modules/survey/list/types/surveys"; import { Button } from "@/modules/ui/components/button"; import { Checkbox } from "@/modules/ui/components/checkbox"; import { FormControl, FormField, FormItem, FormProvider } from "@/modules/ui/components/form"; @@ -14,19 +11,15 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslate } from "@tolgee/react"; import { useFieldArray, useForm } from "react-hook-form"; import toast from "react-hot-toast"; -import { TProject } from "@formbricks/types/project"; -export const CopySurveyForm = ({ - defaultProjects, - survey, - onCancel, - setOpen, -}: { - defaultProjects: TProject[]; +interface ICopySurveyFormProps { + defaultProjects: TUserProject[]; survey: TSurvey; onCancel: () => void; setOpen: (value: boolean) => void; -}) => { +} + +export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: ICopySurveyFormProps) => { const { t } = useTranslate(); const form = useForm({ resolver: zodResolver(ZSurveyCopyFormValidation), diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/CopySurveyModal.tsx b/apps/web/modules/survey/list/components/copy-survey-modal.tsx similarity index 91% rename from apps/web/app/(app)/environments/[environmentId]/surveys/components/CopySurveyModal.tsx rename to apps/web/modules/survey/list/components/copy-survey-modal.tsx index ac13c35472..9f200ee472 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/CopySurveyModal.tsx +++ b/apps/web/modules/survey/list/components/copy-survey-modal.tsx @@ -1,10 +1,10 @@ "use client"; -import { TSurvey } from "@/app/(app)/environments/[environmentId]/surveys/types/surveys"; +import { TSurvey } from "@/modules/survey/list/types/surveys"; import { Modal } from "@/modules/ui/components/modal"; import { useTranslate } from "@tolgee/react"; import { MousePointerClickIcon } from "lucide-react"; -import SurveyCopyOptions from "./SurveyCopyOptions"; +import SurveyCopyOptions from "./survey-copy-options"; interface CopySurveyModalProps { open: boolean; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SortOption.tsx b/apps/web/modules/survey/list/components/sort-option.tsx similarity index 100% rename from apps/web/app/(app)/environments/[environmentId]/surveys/components/SortOption.tsx rename to apps/web/modules/survey/list/components/sort-option.tsx diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyCard.tsx b/apps/web/modules/survey/list/components/survey-card.tsx similarity index 85% rename from apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyCard.tsx rename to apps/web/modules/survey/list/components/survey-card.tsx index 4cee6a1b99..55daff5e61 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyCard.tsx +++ b/apps/web/modules/survey/list/components/survey-card.tsx @@ -1,9 +1,9 @@ "use client"; -import { generateSingleUseIdAction } from "@/app/(app)/environments/[environmentId]/surveys/actions"; -import { SurveyTypeIndicator } from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyTypeIndicator"; -import { TSurvey } from "@/app/(app)/environments/[environmentId]/surveys/types/surveys"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { generateSingleUseIdAction } from "@/modules/survey/list/actions"; +import { SurveyTypeIndicator } from "@/modules/survey/list/components/survey-type-indicator"; +import { TSurvey } from "@/modules/survey/list/types/surveys"; import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator"; import { useTranslate } from "@tolgee/react"; import Link from "next/link"; @@ -11,14 +11,12 @@ import { useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; import { cn } from "@formbricks/lib/cn"; import { convertDateString, timeSince } from "@formbricks/lib/time"; -import { TEnvironment } from "@formbricks/types/environment"; import { TUserLocale } from "@formbricks/types/user"; -import { SurveyDropDownMenu } from "./SurveyDropdownMenu"; +import { SurveyDropDownMenu } from "./survey-dropdown-menu"; interface SurveyCardProps { survey: TSurvey; - environment: TEnvironment; - otherEnvironment: TEnvironment; + environmentId: string; isReadOnly: boolean; WEBAPP_URL: string; duplicateSurvey: (survey: TSurvey) => void; @@ -27,8 +25,7 @@ interface SurveyCardProps { } export const SurveyCard = ({ survey, - environment, - otherEnvironment, + environmentId, isReadOnly, WEBAPP_URL, deleteSurvey, @@ -76,9 +73,9 @@ export const SurveyCard = ({ const linkHref = useMemo(() => { return survey.status === "draft" - ? `/environments/${environment.id}/surveys/${survey.id}/edit` - : `/environments/${environment.id}/surveys/${survey.id}/summary`; - }, [survey.status, survey.id, environment.id]); + ? `/environments/${environmentId}/surveys/${survey.id}/edit` + : `/environments/${environmentId}/surveys/${survey.id}/summary`; + }, [survey.status, survey.id, environmentId]); const isDraftAndReadOnly = survey.status === "draft" && isReadOnly; @@ -123,9 +120,7 @@ export const SurveyCard = ({ { - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [projectLoading, setProjectLoading] = useState(true); useEffect(() => { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyDropdownMenu.tsx b/apps/web/modules/survey/list/components/survey-dropdown-menu.tsx similarity index 95% rename from apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyDropdownMenu.tsx rename to apps/web/modules/survey/list/components/survey-dropdown-menu.tsx index 1bfb136653..30b59b497e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyDropdownMenu.tsx +++ b/apps/web/modules/survey/list/components/survey-dropdown-menu.tsx @@ -1,12 +1,12 @@ "use client"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { copySurveyToOtherEnvironmentAction, deleteSurveyAction, getSurveyAction, -} from "@/app/(app)/environments/[environmentId]/surveys/actions"; -import { TSurvey } from "@/app/(app)/environments/[environmentId]/surveys/types/surveys"; -import { getFormattedErrorMessage } from "@/lib/utils/helper"; +} from "@/modules/survey/list/actions"; +import { TSurvey } from "@/modules/survey/list/types/surveys"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { DropdownMenu, @@ -30,14 +30,11 @@ import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; import toast from "react-hot-toast"; import { cn } from "@formbricks/lib/cn"; -import type { TEnvironment } from "@formbricks/types/environment"; -import { CopySurveyModal } from "./CopySurveyModal"; +import { CopySurveyModal } from "./copy-survey-modal"; interface SurveyDropDownMenuProps { environmentId: string; survey: TSurvey; - environment: TEnvironment; - otherEnvironment: TEnvironment; webAppUrl: string; singleUseId?: string; disabled?: boolean; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyFilterDropdown.tsx b/apps/web/modules/survey/list/components/survey-filter-dropdown.tsx similarity index 100% rename from apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyFilterDropdown.tsx rename to apps/web/modules/survey/list/components/survey-filter-dropdown.tsx diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyFilters.tsx b/apps/web/modules/survey/list/components/survey-filters.tsx similarity index 96% rename from apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyFilters.tsx rename to apps/web/modules/survey/list/components/survey-filters.tsx index 0cd0bd13d3..bc64a53fc2 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyFilters.tsx +++ b/apps/web/modules/survey/list/components/survey-filters.tsx @@ -1,7 +1,7 @@ "use client"; -import { SortOption } from "@/app/(app)/environments/[environmentId]/surveys/components/SortOption"; -import { initialFilters } from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyList"; +import { SortOption } from "@/modules/survey/list/components/sort-option"; +import { initialFilters } from "@/modules/survey/list/components/survey-list"; import { Button } from "@/modules/ui/components/button"; import { DropdownMenu, @@ -15,7 +15,7 @@ import { useState } from "react"; import { useDebounce } from "react-use"; import { TProjectConfigChannel } from "@formbricks/types/project"; import { TFilterOption, TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types"; -import { SurveyFilterDropdown } from "./SurveyFilterDropdown"; +import { SurveyFilterDropdown } from "./survey-filter-dropdown"; interface SurveyFilterProps { surveyFilters: TSurveyFilters; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyList.tsx b/apps/web/modules/survey/list/components/survey-list.tsx similarity index 86% rename from apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyList.tsx rename to apps/web/modules/survey/list/components/survey-list.tsx index 54a972f9c0..275929459b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyList.tsx +++ b/apps/web/modules/survey/list/components/survey-list.tsx @@ -1,25 +1,23 @@ "use client"; -import { getSurveysAction } from "@/app/(app)/environments/[environmentId]/surveys/actions"; -import { getFormattedFilters } from "@/app/(app)/environments/[environmentId]/surveys/lib/utils"; -import { TSurvey } from "@/app/(app)/environments/[environmentId]/surveys/types/surveys"; +import { getSurveysAction } from "@/modules/survey/list/actions"; +import { getFormattedFilters } from "@/modules/survey/list/lib/utils"; +import { TSurvey } from "@/modules/survey/list/types/surveys"; import { Button } from "@/modules/ui/components/button"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useTranslate } from "@tolgee/react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage"; -import { TEnvironment } from "@formbricks/types/environment"; import { wrapThrows } from "@formbricks/types/error-handlers"; import { TProjectConfigChannel } from "@formbricks/types/project"; import { TSurveyFilters } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; -import { SurveyCard } from "./SurveyCard"; -import { SurveyFilters } from "./SurveyFilters"; -import { SurveyLoading } from "./SurveyLoading"; +import { SurveyCard } from "./survey-card"; +import { SurveyFilters } from "./survey-filters"; +import { SurveyLoading } from "./survey-loading"; interface SurveysListProps { - environment: TEnvironment; - otherEnvironment: TEnvironment; + environmentId: string; isReadOnly: boolean; WEBAPP_URL: string; userId: string; @@ -37,8 +35,7 @@ export const initialFilters: TSurveyFilters = { }; export const SurveysList = ({ - environment, - otherEnvironment, + environmentId, isReadOnly, WEBAPP_URL, userId, @@ -84,7 +81,7 @@ export const SurveysList = ({ const fetchInitialSurveys = async () => { setIsFetching(true); const res = await getSurveysAction({ - environmentId: environment.id, + environmentId, limit: surveysLimit, offset: undefined, filterCriteria: filters, @@ -101,12 +98,12 @@ export const SurveysList = ({ }; fetchInitialSurveys(); } - }, [environment.id, surveysLimit, filters, isFilterInitialized]); + }, [environmentId, surveysLimit, filters, isFilterInitialized]); const fetchNextPage = useCallback(async () => { setIsFetching(true); const res = await getSurveysAction({ - environmentId: environment.id, + environmentId, limit: surveysLimit, offset: surveys.length, filterCriteria: filters, @@ -121,7 +118,7 @@ export const SurveysList = ({ setSurveys([...surveys, ...res.data]); setIsFetching(false); } - }, [environment.id, surveys, surveysLimit, filters]); + }, [environmentId, surveys, surveysLimit, filters]); const handleDeleteSurvey = async (surveyId: string) => { const newSurveys = surveys.filter((survey) => survey.id !== surveyId); @@ -157,8 +154,7 @@ export const SurveysList = ({ => + cache( + async () => { + const environment = await prisma.environment.findUnique({ + where: { + id: environmentId, + }, + select: { + id: true, + }, + }); + + if (!environment) { + throw new ResourceNotFoundError("Environment", environmentId); + } + + return environment.id; + }, + + [`survey-list-doesEnvironmentExist-${environmentId}`], + { + tags: [environmentCache.tag.byId(environmentId)], + } + )() +); + +export const getProjectIdIfEnvironmentExists = reactCache( + async (environmentId: string): Promise => + cache( + async () => { + const environment = await prisma.environment.findUnique({ + where: { + id: environmentId, + }, + select: { + projectId: true, + }, + }); + + if (!environment) { + throw new ResourceNotFoundError("Environment", environmentId); + } + + return environment.projectId; + }, + [`survey-list-getProjectIdIfEnvironmentExists-${environmentId}`], + { + tags: [environmentCache.tag.byId(environmentId)], + } + )() +); + +export const getEnvironment = reactCache( + async (environmentId: string): Promise | null> => + cache( + async () => { + validateInputs([environmentId, z.string().cuid2()]); + + try { + const environment = await prisma.environment.findUnique({ + where: { + id: environmentId, + }, + select: { + id: true, + type: true, + }, + }); + return environment; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`survey-list-getEnvironment-${environmentId}`], + { + tags: [environmentCache.tag.byId(environmentId)], + } + )() +); diff --git a/apps/web/modules/survey/list/lib/organization.ts b/apps/web/modules/survey/list/lib/organization.ts new file mode 100644 index 0000000000..72a10f6996 --- /dev/null +++ b/apps/web/modules/survey/list/lib/organization.ts @@ -0,0 +1,40 @@ +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { environmentCache } from "@formbricks/lib/environment/cache"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; + +export const getOrganizationIdByEnvironmentId = reactCache( + async (environmentId: string): Promise => + cache( + async () => { + const organization = await prisma.organization.findFirst({ + where: { + projects: { + some: { + environments: { + some: { + id: environmentId, + }, + }, + }, + }, + }, + select: { + id: true, + }, + }); + + if (!organization) { + throw new ResourceNotFoundError("Organization", null); + } + + return organization.id; + }, + + [`survey-list-getOrganizationIdByEnvironmentId-${environmentId}`], + { + tags: [environmentCache.tag.byId(environmentId)], + } + )() +); diff --git a/apps/web/modules/survey/list/lib/project.ts b/apps/web/modules/survey/list/lib/project.ts new file mode 100644 index 0000000000..7adea354cb --- /dev/null +++ b/apps/web/modules/survey/list/lib/project.ts @@ -0,0 +1,81 @@ +import "server-only"; +import { TUserProject } from "@/modules/survey/list/types/projects"; +import { TProjectWithLanguages } from "@/modules/survey/list/types/surveys"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { projectCache } from "@formbricks/lib/project/cache"; +import { DatabaseError } from "@formbricks/types/errors"; + +export const getProjectWithLanguagesByEnvironmentId = reactCache( + async (environmentId: string): Promise => + cache( + async () => { + try { + const projectPrisma = await prisma.project.findFirst({ + where: { + environments: { + some: { + id: environmentId, + }, + }, + }, + select: { + id: true, + languages: true, + }, + }); + + return projectPrisma; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } + }, + [`survey-list-getProjectByEnvironmentId-${environmentId}`], + { + tags: [projectCache.tag.byEnvironmentId(environmentId)], + } + )() +); + +export const getUserProjects = reactCache( + async (userId: string, organizationId: string): Promise => + cache( + async () => { + try { + const projects = await prisma.project.findMany({ + where: { + organizationId, + }, + select: { + id: true, + name: true, + environments: { + select: { + id: true, + type: true, + }, + }, + }, + }); + + return projects; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`survey-list-getUserProjects-${userId}-${organizationId}`], + { + tags: [projectCache.tag.byUserId(userId), projectCache.tag.byOrganizationId(organizationId)], + } + )() +); diff --git a/apps/web/modules/survey/list/lib/survey.ts b/apps/web/modules/survey/list/lib/survey.ts new file mode 100644 index 0000000000..29170f89ee --- /dev/null +++ b/apps/web/modules/survey/list/lib/survey.ts @@ -0,0 +1,643 @@ +import "server-only"; +import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils"; +import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment"; +import { getProjectWithLanguagesByEnvironmentId } from "@/modules/survey/list/lib/project"; +import { TProjectWithLanguages, TSurvey } from "@/modules/survey/list/types/surveys"; +import { createId } from "@paralleldrive/cuid2"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { z } from "zod"; +import { prisma } from "@formbricks/database"; +import { actionClassCache } from "@formbricks/lib/actionClass/cache"; +import { cache } from "@formbricks/lib/cache"; +import { segmentCache } from "@formbricks/lib/cache/segment"; +import { projectCache } from "@formbricks/lib/project/cache"; +import { responseCache } from "@formbricks/lib/response/cache"; +import { surveyCache } from "@formbricks/lib/survey/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TSurveyFilterCriteria } from "@formbricks/types/surveys/types"; + +export const surveySelect: Prisma.SurveySelect = { + id: true, + createdAt: true, + updatedAt: true, + name: true, + type: true, + creator: { + select: { + name: true, + }, + }, + status: true, + singleUse: true, + environmentId: true, + _count: { + select: { responses: true }, + }, +}; + +export const getSurveys = reactCache( + async ( + environmentId: string, + limit?: number, + offset?: number, + filterCriteria?: TSurveyFilterCriteria + ): Promise => + cache( + async () => { + try { + if (filterCriteria?.sortBy === "relevance") { + // Call the sortByRelevance function + return await getSurveysSortedByRelevance(environmentId, limit, offset ?? 0, filterCriteria); + } + + // Fetch surveys normally with pagination and include response count + const surveysPrisma = await prisma.survey.findMany({ + where: { + environmentId, + ...buildWhereClause(filterCriteria), + }, + select: surveySelect, + orderBy: buildOrderByClause(filterCriteria?.sortBy), + take: limit, + skip: offset, + }); + + return surveysPrisma.map((survey) => { + return { + ...survey, + responseCount: survey._count.responses, + }; + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } + }, + [`surveyList-getSurveys-${environmentId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`], + { + tags: [ + surveyCache.tag.byEnvironmentId(environmentId), + responseCache.tag.byEnvironmentId(environmentId), + ], + } + )() +); + +export const getSurveysSortedByRelevance = reactCache( + async ( + environmentId: string, + limit?: number, + offset?: number, + filterCriteria?: TSurveyFilterCriteria + ): Promise => + cache( + async () => { + try { + let surveys: TSurvey[] = []; + + const inProgressSurveyCount = await prisma.survey.count({ + where: { + environmentId, + status: "inProgress", + ...buildWhereClause(filterCriteria), + }, + }); + + // Fetch surveys that are in progress first + const inProgressSurveys = + offset && offset > inProgressSurveyCount + ? [] + : await prisma.survey.findMany({ + where: { + environmentId, + status: "inProgress", + ...buildWhereClause(filterCriteria), + }, + select: surveySelect, + orderBy: buildOrderByClause("updatedAt"), + take: limit, + skip: offset, + }); + + surveys = inProgressSurveys.map((survey) => { + return { + ...survey, + responseCount: survey._count.responses, + }; + }); + + // Determine if additional surveys are needed + if (offset !== undefined && limit && inProgressSurveys.length < limit) { + const remainingLimit = limit - inProgressSurveys.length; + const newOffset = Math.max(0, offset - inProgressSurveyCount); + const additionalSurveys = await prisma.survey.findMany({ + where: { + environmentId, + status: { not: "inProgress" }, + ...buildWhereClause(filterCriteria), + }, + select: surveySelect, + orderBy: buildOrderByClause("updatedAt"), + take: remainingLimit, + skip: newOffset, + }); + + surveys = [ + ...surveys, + ...additionalSurveys.map((survey) => { + return { + ...survey, + responseCount: survey._count.responses, + }; + }), + ]; + } + + return surveys; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } + }, + [ + `surveyList-getSurveysSortedByRelevance-${environmentId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`, + ], + { + tags: [ + surveyCache.tag.byEnvironmentId(environmentId), + responseCache.tag.byEnvironmentId(environmentId), + ], + } + )() +); + +export const getSurvey = reactCache( + async (surveyId: string): Promise => + cache( + async () => { + let surveyPrisma; + try { + surveyPrisma = await prisma.survey.findUnique({ + where: { + id: surveyId, + }, + select: surveySelect, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } + + if (!surveyPrisma) { + return null; + } + + return { ...surveyPrisma, responseCount: surveyPrisma?._count.responses }; + }, + [`surveyList-getSurvey-${surveyId}`], + { + tags: [surveyCache.tag.byId(surveyId), responseCache.tag.bySurveyId(surveyId)], + } + )() +); + +export const deleteSurvey = async (surveyId: string): Promise => { + try { + const deletedSurvey = await prisma.survey.delete({ + where: { + id: surveyId, + }, + select: { + id: true, + environmentId: true, + segment: { + select: { + id: true, + isPrivate: true, + }, + }, + type: true, + resultShareKey: true, + triggers: { + select: { + actionClass: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + + if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) { + const deletedSegment = await prisma.segment.delete({ + where: { + id: deletedSurvey.segment.id, + }, + }); + + if (deletedSegment) { + segmentCache.revalidate({ + id: deletedSegment.id, + environmentId: deletedSurvey.environmentId, + }); + } + } + + responseCache.revalidate({ + surveyId, + environmentId: deletedSurvey.environmentId, + }); + surveyCache.revalidate({ + id: deletedSurvey.id, + environmentId: deletedSurvey.environmentId, + resultShareKey: deletedSurvey.resultShareKey ?? undefined, + }); + + if (deletedSurvey.segment?.id) { + segmentCache.revalidate({ + id: deletedSurvey.segment.id, + environmentId: deletedSurvey.environmentId, + }); + } + + // Revalidate public triggers by actionClassId + deletedSurvey.triggers.forEach((trigger) => { + surveyCache.revalidate({ + actionClassId: trigger.actionClass.id, + }); + }); + + return true; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +const getExistingSurvey = async (surveyId: string) => { + return await prisma.survey.findUnique({ + where: { + id: surveyId, + }, + select: { + name: true, + type: true, + languages: { + select: { + default: true, + enabled: true, + language: { + select: { + code: true, + alias: true, + }, + }, + }, + }, + welcomeCard: true, + questions: true, + endings: true, + variables: true, + hiddenFields: true, + surveyClosedMessage: true, + singleUse: true, + projectOverwrites: true, + styling: true, + segment: true, + followUps: true, + triggers: { + select: { + actionClass: { + select: { + id: true, + name: true, + environmentId: true, + description: true, + type: true, + key: true, + noCodeConfig: true, + }, + }, + }, + }, + }, + }); +}; + +export const copySurveyToOtherEnvironment = async ( + environmentId: string, + surveyId: string, + targetEnvironmentId: string, + userId: string +) => { + try { + const isSameEnvironment = environmentId === targetEnvironmentId; + + // Fetch required resources + const [existingEnvironment, existingProject, existingSurvey] = await Promise.all([ + doesEnvironmentExist(environmentId), + getProjectWithLanguagesByEnvironmentId(environmentId), + getExistingSurvey(surveyId), + ]); + + if (!existingEnvironment) throw new ResourceNotFoundError("Environment", environmentId); + if (!existingProject) throw new ResourceNotFoundError("Project", environmentId); + if (!existingSurvey) throw new ResourceNotFoundError("Survey", surveyId); + + let targetEnvironment: string | null = null; + let targetProject: TProjectWithLanguages | null = null; + + if (isSameEnvironment) { + targetEnvironment = existingEnvironment; + targetProject = existingProject; + } else { + [targetEnvironment, targetProject] = await Promise.all([ + doesEnvironmentExist(targetEnvironmentId), + getProjectWithLanguagesByEnvironmentId(targetEnvironmentId), + ]); + + if (!targetEnvironment) throw new ResourceNotFoundError("Environment", targetEnvironmentId); + if (!targetProject) throw new ResourceNotFoundError("Project", targetEnvironmentId); + } + + const { ...restExistingSurvey } = existingSurvey; + const hasLanguages = existingSurvey.languages && existingSurvey.languages.length > 0; + + // Prepare survey data + const surveyData: Prisma.SurveyCreateInput = { + ...restExistingSurvey, + id: createId(), + name: `${existingSurvey.name} (copy)`, + type: existingSurvey.type, + status: "draft", + welcomeCard: structuredClone(existingSurvey.welcomeCard), + questions: structuredClone(existingSurvey.questions), + endings: structuredClone(existingSurvey.endings), + variables: structuredClone(existingSurvey.variables), + hiddenFields: structuredClone(existingSurvey.hiddenFields), + languages: hasLanguages + ? { + create: existingSurvey.languages.map((surveyLanguage) => ({ + language: { + connectOrCreate: { + where: { + projectId_code: { code: surveyLanguage.language.code, projectId: targetProject.id }, + }, + create: { + code: surveyLanguage.language.code, + alias: surveyLanguage.language.alias, + projectId: targetProject.id, + }, + }, + }, + default: surveyLanguage.default, + enabled: surveyLanguage.enabled, + })), + } + : undefined, + triggers: { + create: existingSurvey.triggers.map((trigger): Prisma.SurveyTriggerCreateWithoutSurveyInput => { + const baseActionClassData = { + name: trigger.actionClass.name, + environment: { connect: { id: targetEnvironmentId } }, + description: trigger.actionClass.description, + type: trigger.actionClass.type, + }; + + if (isSameEnvironment) { + return { + actionClass: { connect: { id: trigger.actionClass.id } }, + }; + } else if (trigger.actionClass.type === "code") { + return { + actionClass: { + connectOrCreate: { + where: { + key_environmentId: { key: trigger.actionClass.key!, environmentId: targetEnvironmentId }, + }, + create: { + ...baseActionClassData, + key: trigger.actionClass.key, + }, + }, + }, + }; + } else { + return { + actionClass: { + connectOrCreate: { + where: { + name_environmentId: { + name: trigger.actionClass.name, + environmentId: targetEnvironmentId, + }, + }, + create: { + ...baseActionClassData, + noCodeConfig: trigger.actionClass.noCodeConfig + ? structuredClone(trigger.actionClass.noCodeConfig) + : undefined, + }, + }, + }, + }; + } + }), + }, + environment: { + connect: { + id: targetEnvironmentId, + }, + }, + creator: { + connect: { + id: userId, + }, + }, + surveyClosedMessage: existingSurvey.surveyClosedMessage + ? structuredClone(existingSurvey.surveyClosedMessage) + : Prisma.JsonNull, + singleUse: existingSurvey.singleUse ? structuredClone(existingSurvey.singleUse) : Prisma.JsonNull, + projectOverwrites: existingSurvey.projectOverwrites + ? structuredClone(existingSurvey.projectOverwrites) + : Prisma.JsonNull, + styling: existingSurvey.styling ? structuredClone(existingSurvey.styling) : Prisma.JsonNull, + segment: undefined, + followUps: { + createMany: { + data: existingSurvey.followUps.map((followUp) => ({ + name: followUp.name, + trigger: followUp.trigger, + action: followUp.action, + })), + }, + }, + }; + + // Handle segment + if (existingSurvey.segment) { + if (existingSurvey.segment.isPrivate) { + surveyData.segment = { + create: { + title: surveyData.id!, + isPrivate: true, + filters: existingSurvey.segment.filters, + environment: { connect: { id: targetEnvironmentId } }, + }, + }; + } else if (isSameEnvironment) { + surveyData.segment = { connect: { id: existingSurvey.segment.id } }; + } else { + const existingSegmentInTargetEnvironment = await prisma.segment.findFirst({ + where: { + title: existingSurvey.segment.title, + isPrivate: false, + environmentId: targetEnvironmentId, + }, + }); + + surveyData.segment = { + create: { + title: existingSegmentInTargetEnvironment + ? `${existingSurvey.segment.title}-${Date.now()}` + : existingSurvey.segment.title, + isPrivate: false, + filters: existingSurvey.segment.filters, + environment: { connect: { id: targetEnvironmentId } }, + }, + }; + } + } + + const targetProjectLanguageCodes = targetProject.languages.map((language) => language.code); + const newSurvey = await prisma.survey.create({ + data: surveyData, + select: { + id: true, + environmentId: true, + segment: { + select: { + id: true, + }, + }, + triggers: { + select: { + actionClass: { + select: { + id: true, + name: true, + environmentId: true, + }, + }, + }, + }, + languages: { + select: { + language: { + select: { + code: true, + }, + }, + }, + }, + resultShareKey: true, + }, + }); + + // Identify newly created action classes + const newActionClasses = newSurvey.triggers.map((trigger) => trigger.actionClass); + + // Revalidate cache only for newly created action classes + for (const actionClass of newActionClasses) { + actionClassCache.revalidate({ + environmentId: actionClass.environmentId, + name: actionClass.name, + id: actionClass.id, + }); + } + + let newLanguageCreated = false; + if (existingSurvey.languages && existingSurvey.languages.length > 0) { + const targetLanguageCodes = newSurvey.languages.map((lang) => lang.language.code); + newLanguageCreated = targetLanguageCodes.length > targetProjectLanguageCodes.length; + } + + // Invalidate caches + if (newLanguageCreated) { + projectCache.revalidate({ id: targetProject.id, environmentId: targetEnvironmentId }); + } + + surveyCache.revalidate({ + id: newSurvey.id, + environmentId: newSurvey.environmentId, + resultShareKey: newSurvey.resultShareKey ?? undefined, + }); + + existingSurvey.triggers.forEach((trigger) => { + surveyCache.revalidate({ + actionClassId: trigger.actionClass.id, + }); + }); + + if (newSurvey.segment) { + segmentCache.revalidate({ + id: newSurvey.segment.id, + environmentId: newSurvey.environmentId, + }); + } + + return newSurvey; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const getSurveyCount = reactCache( + async (environmentId: string): Promise => + cache( + async () => { + validateInputs([environmentId, z.string().cuid2()]); + try { + const surveyCount = await prisma.survey.count({ + where: { + environmentId: environmentId, + }, + }); + + return surveyCount; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getSurveyCount-${environmentId}`], + { + tags: [surveyCache.tag.byEnvironmentId(environmentId)], + } + )() +); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/lib/utils.ts b/apps/web/modules/survey/list/lib/utils.ts similarity index 100% rename from apps/web/app/(app)/environments/[environmentId]/surveys/lib/utils.ts rename to apps/web/modules/survey/list/lib/utils.ts diff --git a/apps/web/modules/survey/list/loading.tsx b/apps/web/modules/survey/list/loading.tsx new file mode 100644 index 0000000000..400517e047 --- /dev/null +++ b/apps/web/modules/survey/list/loading.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { SurveyLoading } from "@/modules/survey/list/components/survey-loading"; +import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; +import { PageHeader } from "@/modules/ui/components/page-header"; +import { useTranslate } from "@tolgee/react"; + +export const SurveyListLoading = () => { + const { t } = useTranslate(); + + return ( + + +
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+
+
+
+ +
+ ); +}; diff --git a/apps/web/modules/survey/list/page.tsx b/apps/web/modules/survey/list/page.tsx new file mode 100644 index 0000000000..f13bcca386 --- /dev/null +++ b/apps/web/modules/survey/list/page.tsx @@ -0,0 +1,135 @@ +import { authOptions } from "@/modules/auth/lib/authOptions"; +import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; +import { TemplateList } from "@/modules/survey/components/template-list"; +import { getMembershipRoleByUserIdOrganizationId } from "@/modules/survey/lib/membership"; +import { getProjectByEnvironmentId } from "@/modules/survey/lib/project"; +import { SurveysList } from "@/modules/survey/list/components/survey-list"; +import { getEnvironment } from "@/modules/survey/list/lib/environment"; +import { getOrganizationIdByEnvironmentId } from "@/modules/survey/list/lib/organization"; +import { getSurveyCount } from "@/modules/survey/list/lib/survey"; +import { Button } from "@/modules/ui/components/button"; +import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; +import { PageHeader } from "@/modules/ui/components/page-header"; +import { getTranslate } from "@/tolgee/server"; +import { PlusIcon } from "lucide-react"; +import { Metadata } from "next"; +import { getServerSession } from "next-auth"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { SURVEYS_PER_PAGE, WEBAPP_URL } from "@formbricks/lib/constants"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { findMatchingLocale } from "@formbricks/lib/utils/locale"; +import { TTemplateRole } from "@formbricks/types/templates"; + +export const metadata: Metadata = { + title: "Your Surveys", +}; + +interface SurveyTemplateProps { + params: Promise<{ + environmentId: string; + }>; + searchParams: Promise<{ + role?: TTemplateRole; + }>; +} + +export const SurveysPage = async ({ + params: paramsProps, + searchParams: searchParamsProps, +}: SurveyTemplateProps) => { + const searchParams = await searchParamsProps; + const params = await paramsProps; + + const session = await getServerSession(authOptions); + const project = await getProjectByEnvironmentId(params.environmentId); + const organizationId = await getOrganizationIdByEnvironmentId(params.environmentId); + const t = await getTranslate(); + if (!session) { + throw new Error(t("common.session_not_found")); + } + + if (!project) { + throw new Error(t("common.project_not_found")); + } + + if (!organizationId) { + throw new Error(t("common.organization_not_found")); + } + + const prefilledFilters = [project?.config.channel, project.config.industry, searchParams.role ?? null]; + + const membershipRole = await getMembershipRoleByUserIdOrganizationId(session?.user.id, organizationId); + const { isMember, isBilling } = getAccessFlags(membershipRole); + + const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id); + const { hasReadAccess } = getTeamPermissionFlags(projectPermission); + + const isReadOnly = isMember && hasReadAccess; + + if (isBilling) { + return redirect(`/environments/${params.environmentId}/settings/billing`); + } + + const environment = await getEnvironment(params.environmentId); + if (!environment) { + throw new Error(t("common.environment_not_found")); + } + + const surveyCount = await getSurveyCount(params.environmentId); + + const currentProjectChannel = project.config.channel ?? null; + const locale = await findMatchingLocale(); + const CreateSurveyButton = () => { + return ( + + ); + }; + + return ( + + {surveyCount > 0 ? ( + <> + : } /> + + + ) : isReadOnly ? ( + <> +

+ {t("environments.surveys.no_surveys_created_yet")} +

+ +

+ {t("environments.surveys.read_only_user_not_allowed_to_create_survey_warning")} +

+ + ) : ( + <> +

+ {t("environments.surveys.all_set_time_to_create_first_survey")} +

+ + + )} +
+ ); +}; diff --git a/apps/web/modules/survey/list/types/projects.ts b/apps/web/modules/survey/list/types/projects.ts new file mode 100644 index 0000000000..7e443ad89f --- /dev/null +++ b/apps/web/modules/survey/list/types/projects.ts @@ -0,0 +1,5 @@ +import { Environment, Project } from "@prisma/client"; + +export interface TUserProject extends Pick { + environments: Pick[]; +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/types/surveys.ts b/apps/web/modules/survey/list/types/surveys.ts similarity index 83% rename from apps/web/app/(app)/environments/[environmentId]/surveys/types/surveys.ts rename to apps/web/modules/survey/list/types/surveys.ts index b996248670..4b4a31a64f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/types/surveys.ts +++ b/apps/web/modules/survey/list/types/surveys.ts @@ -1,3 +1,4 @@ +import { Language, Project } from "@prisma/client"; import { z } from "zod"; import { ZSurveyStatus } from "@formbricks/types/surveys/types"; @@ -35,3 +36,7 @@ export const ZSurveyCopyFormValidation = z.object({ }); export type TSurveyCopyFormData = z.infer; + +export interface TProjectWithLanguages extends Pick { + languages: Pick[]; +} diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions.ts b/apps/web/modules/survey/templates/actions.ts similarity index 86% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions.ts rename to apps/web/modules/survey/templates/actions.ts index 4193e2b6a1..b341f3dbef 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions.ts +++ b/apps/web/modules/survey/templates/actions.ts @@ -4,18 +4,17 @@ import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper"; import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils"; +import { getOrganizationAIKeys } from "@/modules/survey/lib/organization"; +import { createSurvey } from "@/modules/survey/templates/lib/survey"; import { createId } from "@paralleldrive/cuid2"; import { generateObject } from "ai"; import { z } from "zod"; import { llmModel } from "@formbricks/lib/aiModels"; -import { getOrganization } from "@formbricks/lib/organization/service"; -import { createSurvey } from "@formbricks/lib/survey/service"; -import { ZId, ZString } from "@formbricks/types/common"; import { ZSurveyQuestion } from "@formbricks/types/surveys/types"; const ZCreateAISurveyAction = z.object({ - environmentId: ZId, - prompt: ZString, + environmentId: z.string().cuid2(), + prompt: z.string(), }); export const createAISurveyAction = authenticatedActionClient @@ -39,13 +38,16 @@ export const createAISurveyAction = authenticatedActionClient ], }); - const organization = await getOrganization(organizationId); + const organization = await getOrganizationAIKeys(organizationId); if (!organization) { throw new Error("Organization not found"); } - const isAIEnabled = await getIsAIEnabled(organization); + const isAIEnabled = await getIsAIEnabled({ + isAIEnabled: organization.isAIEnabled, + billing: organization.billing, + }); if (!isAIEnabled) { throw new Error("AI is not enabled for this organization"); diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/BackButton.tsx b/apps/web/modules/survey/templates/components/back-button.tsx similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/BackButton.tsx rename to apps/web/modules/survey/templates/components/back-button.tsx diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/FormbricksAICard.tsx b/apps/web/modules/survey/templates/components/formbricks-ai-card.tsx similarity index 95% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/FormbricksAICard.tsx rename to apps/web/modules/survey/templates/components/formbricks-ai-card.tsx index b66888e545..d8836e9c49 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/FormbricksAICard.tsx +++ b/apps/web/modules/survey/templates/components/formbricks-ai-card.tsx @@ -1,7 +1,7 @@ "use client"; -import { createAISurveyAction } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { createAISurveyAction } from "@/modules/survey/templates/actions"; import { Button } from "@/modules/ui/components/button"; import { Card, diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/MenuBar.tsx b/apps/web/modules/survey/templates/components/menu-bar.tsx similarity index 71% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/MenuBar.tsx rename to apps/web/modules/survey/templates/components/menu-bar.tsx index 662ee00546..74a6465414 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/MenuBar.tsx +++ b/apps/web/modules/survey/templates/components/menu-bar.tsx @@ -1,6 +1,6 @@ "use client"; -import { BackButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/BackButton"; +import { BackButton } from "@/modules/survey/templates/components/back-button"; export const MenuBar = () => { return ( diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/TemplateContainer.tsx b/apps/web/modules/survey/templates/components/template-container.tsx similarity index 79% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/TemplateContainer.tsx rename to apps/web/modules/survey/templates/components/template-container.tsx index 9ca9a53062..b8a7b30467 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/TemplateContainer.tsx +++ b/apps/web/modules/survey/templates/components/template-container.tsx @@ -1,25 +1,24 @@ "use client"; -import { FormbricksAICard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/FormbricksAICard"; -import { MenuBar } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/MenuBar"; import { customSurveyTemplate } from "@/app/lib/templates"; -import { TemplateList } from "@/modules/surveys/components/TemplateList"; +import { TemplateList } from "@/modules/survey/components/template-list"; +import { FormbricksAICard } from "@/modules/survey/templates/components/formbricks-ai-card"; +import { MenuBar } from "@/modules/survey/templates/components/menu-bar"; import { PreviewSurvey } from "@/modules/ui/components/preview-survey"; import { SearchBar } from "@/modules/ui/components/search-bar"; import { Separator } from "@/modules/ui/components/separator"; +import { Project } from "@prisma/client"; +import { Environment } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { useState } from "react"; -import type { TEnvironment } from "@formbricks/types/environment"; -import type { TProject, TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project"; +import type { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project"; import type { TTemplate, TTemplateRole } from "@formbricks/types/templates"; -import { TUser } from "@formbricks/types/user"; -import { getMinimalSurvey } from "../../lib/minimalSurvey"; +import { getMinimalSurvey } from "../lib/minimal-survey"; type TemplateContainerWithPreviewProps = { - environmentId: string; - project: TProject; - environment: TEnvironment; - user: TUser; + project: Project; + environment: Pick; + userId: string; prefilledFilters: (TProjectConfigChannel | TProjectConfigIndustry | TTemplateRole | null)[]; isAIEnabled: boolean; }; @@ -27,7 +26,7 @@ type TemplateContainerWithPreviewProps = { export const TemplateContainerWithPreview = ({ project, environment, - user, + userId, prefilledFilters, isAIEnabled, }: TemplateContainerWithPreviewProps) => { @@ -66,9 +65,9 @@ export const TemplateContainerWithPreview = ({ )} { setActiveQuestionId(template.preset.questions[0].id); diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/lib/minimalSurvey.ts b/apps/web/modules/survey/templates/lib/minimal-survey.ts similarity index 100% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/lib/minimalSurvey.ts rename to apps/web/modules/survey/templates/lib/minimal-survey.ts diff --git a/apps/web/modules/survey/templates/lib/survey.ts b/apps/web/modules/survey/templates/lib/survey.ts new file mode 100644 index 0000000000..3ee89261bf --- /dev/null +++ b/apps/web/modules/survey/templates/lib/survey.ts @@ -0,0 +1,109 @@ +import { getInsightsEnabled } from "@/modules/survey/lib/utils"; +import { doesSurveyHasOpenTextQuestion } from "@/modules/survey/lib/utils"; +import { Prisma, Survey } from "@prisma/client"; +import { prisma } from "@formbricks/database"; +import { segmentCache } from "@formbricks/lib/cache/segment"; +import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer"; +import { surveyCache } from "@formbricks/lib/survey/cache"; +import { DatabaseError } from "@formbricks/types/errors"; + +export const createSurvey = async ( + environmentId: string, + surveyBody: Pick +): Promise<{ id: string }> => { + try { + if (doesSurveyHasOpenTextQuestion(surveyBody.questions ?? [])) { + const openTextQuestions = + surveyBody.questions?.filter((question) => question.type === "openText") ?? []; + const insightsEnabledValues = await Promise.all( + openTextQuestions.map(async (question) => { + const insightsEnabled = await getInsightsEnabled(question); + + return { id: question.id, insightsEnabled }; + }) + ); + + surveyBody.questions = surveyBody.questions?.map((question) => { + const index = insightsEnabledValues.findIndex((item) => item.id === question.id); + if (index !== -1) { + return { + ...question, + insightsEnabled: insightsEnabledValues[index].insightsEnabled, + }; + } + + return question; + }); + } + + const survey = await prisma.survey.create({ + data: { + ...surveyBody, + environment: { + connect: { + id: environmentId, + }, + }, + }, + select: { + id: true, + type: true, + environmentId: true, + resultShareKey: true, + }, + }); + + // if the survey created is an "app" survey, we also create a private segment for it. + if (survey.type === "app") { + const newSegment = await prisma.segment.create({ + data: { + title: survey.id, + filters: [], + isPrivate: true, + environment: { + connect: { + id: environmentId, + }, + }, + }, + }); + + await prisma.survey.update({ + where: { + id: survey.id, + }, + data: { + segment: { + connect: { + id: newSegment.id, + }, + }, + }, + }); + + segmentCache.revalidate({ + id: newSegment.id, + environmentId: survey.environmentId, + }); + } + + surveyCache.revalidate({ + id: survey.id, + environmentId: survey.environmentId, + resultShareKey: survey.resultShareKey ?? undefined, + }); + + await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", { + surveyId: survey.id, + surveyType: survey.type, + }); + + return { id: survey.id }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } +}; diff --git a/apps/web/modules/survey/templates/loading.tsx b/apps/web/modules/survey/templates/loading.tsx new file mode 100644 index 0000000000..1b0d7ea24e --- /dev/null +++ b/apps/web/modules/survey/templates/loading.tsx @@ -0,0 +1,5 @@ +import { LoadingSpinner } from "@/modules/ui/components/loading-spinner"; + +export const SurveyTemplatesLoading = () => { + return ; +}; diff --git a/apps/web/modules/survey/templates/page.tsx b/apps/web/modules/survey/templates/page.tsx new file mode 100644 index 0000000000..b3b9bffc4b --- /dev/null +++ b/apps/web/modules/survey/templates/page.tsx @@ -0,0 +1,75 @@ +import { authOptions } from "@/modules/auth/lib/authOptions"; +import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; +import { getEnvironment } from "@/modules/survey/lib/environment"; +import { getMembershipRoleByUserIdOrganizationId } from "@/modules/survey/lib/membership"; +import { getProjectByEnvironmentId } from "@/modules/survey/lib/project"; +import { getTranslate } from "@/tolgee/server"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project"; +import { TTemplateRole } from "@formbricks/types/templates"; +import { TemplateContainerWithPreview } from "./components/template-container"; + +interface SurveyTemplateProps { + params: Promise<{ + environmentId: string; + }>; + searchParams: Promise<{ + channel?: TProjectConfigChannel; + industry?: TProjectConfigIndustry; + role?: TTemplateRole; + }>; +} + +export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => { + const searchParams = await props.searchParams; + const params = await props.params; + const t = await getTranslate(); + const session = await getServerSession(authOptions); + const environmentId = params.environmentId; + + if (!session) { + throw new Error(t("common.session_not_found")); + } + + const [environment, project] = await Promise.all([ + getEnvironment(environmentId), + getProjectByEnvironmentId(environmentId), + ]); + + if (!project) { + throw new Error(t("common.project_not_found")); + } + + if (!environment) { + throw new Error(t("common.environment_not_found")); + } + const membershipRole = await getMembershipRoleByUserIdOrganizationId( + session?.user.id, + project.organizationId + ); + const { isMember } = getAccessFlags(membershipRole); + + const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id); + const { hasReadAccess } = getTeamPermissionFlags(projectPermission); + + const isReadOnly = isMember && hasReadAccess; + if (isReadOnly) { + return redirect(`/environments/${environment.id}/surveys`); + } + + const prefilledFilters = [project.config.channel, project.config.industry, searchParams.role ?? null]; + + return ( + + ); +}; diff --git a/apps/web/modules/surveys/components/TemplateList/lib/utils.ts b/apps/web/modules/surveys/components/TemplateList/lib/utils.ts deleted file mode 100644 index da5e3fa70e..0000000000 --- a/apps/web/modules/surveys/components/TemplateList/lib/utils.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { TFnType } from "@tolgee/react"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone"; -import { TProject, TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project"; -import { TSurveyQuestion } from "@formbricks/types/surveys/types"; -import { TTemplate, TTemplateRole } from "@formbricks/types/templates"; - -export const replaceQuestionPresetPlaceholders = ( - question: TSurveyQuestion, - project: TProject -): TSurveyQuestion => { - if (!project) return question; - const newQuestion = structuredClone(question); - const defaultLanguageCode = "default"; - if (newQuestion.headline) { - newQuestion.headline[defaultLanguageCode] = getLocalizedValue( - newQuestion.headline, - defaultLanguageCode - ).replace("$[projectName]", project.name); - } - if (newQuestion.subheader) { - newQuestion.subheader[defaultLanguageCode] = getLocalizedValue( - newQuestion.subheader, - defaultLanguageCode - )?.replace("$[projectName]", project.name); - } - return newQuestion; -}; - -// replace all occurences of projectName with the actual project name in the current template -export const replacePresetPlaceholders = (template: TTemplate, project: any) => { - const preset = structuredClone(template.preset); - preset.name = preset.name.replace("$[projectName]", project.name); - preset.questions = preset.questions.map((question) => { - return replaceQuestionPresetPlaceholders(question, project); - }); - return { ...template, preset }; -}; - -export const getChannelMapping = (t: TFnType): { value: TProjectConfigChannel; label: string }[] => [ - { value: "website", label: t("common.website_survey") }, - { value: "app", label: t("common.app_survey") }, - { value: "link", label: t("common.link_survey") }, -]; - -export const getIndustryMapping = (t: TFnType): { value: TProjectConfigIndustry; label: string }[] => [ - { value: "eCommerce", label: t("common.e_commerce") }, - { value: "saas", label: t("common.saas") }, - { value: "other", label: t("common.other") }, -]; - -export const getRoleMapping = (t: TFnType): { value: TTemplateRole; label: string }[] => [ - { value: "productManager", label: t("common.product_manager") }, - { value: "customerSuccess", label: t("common.customer_success") }, - { value: "marketing", label: t("common.marketing") }, - { value: "sales", label: t("common.sales") }, - { value: "peopleManager", label: t("common.people_manager") }, -]; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/BackgroundStylingCard.tsx b/apps/web/modules/ui/components/background-styling-card/index.tsx similarity index 98% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/BackgroundStylingCard.tsx rename to apps/web/modules/ui/components/background-styling-card/index.tsx index c57bdbed51..aa4698ed95 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/BackgroundStylingCard.tsx +++ b/apps/web/modules/ui/components/background-styling-card/index.tsx @@ -1,5 +1,6 @@ "use client"; +import { SurveyBgSelectorTab } from "@/modules/ui/components/background-styling-card/survey-bg-selector-tab"; import { Badge } from "@/modules/ui/components/badge"; import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; import { Slider } from "@/modules/ui/components/slider"; @@ -11,7 +12,6 @@ import { UseFormReturn } from "react-hook-form"; import { cn } from "@formbricks/lib/cn"; import { TProjectStyling } from "@formbricks/types/project"; import { TSurveyStyling } from "@formbricks/types/surveys/types"; -import { SurveyBgSelectorTab } from "./SurveyBgSelectorTab"; interface BackgroundStylingCardProps { open: boolean; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyBgSelectorTab.tsx b/apps/web/modules/ui/components/background-styling-card/survey-bg-selector-tab.tsx similarity index 89% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyBgSelectorTab.tsx rename to apps/web/modules/ui/components/background-styling-card/survey-bg-selector-tab.tsx index abbcfa9ce4..da64a811db 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyBgSelectorTab.tsx +++ b/apps/web/modules/ui/components/background-styling-card/survey-bg-selector-tab.tsx @@ -1,13 +1,13 @@ "use client"; +import { AnimatedSurveyBg } from "@/modules/survey/editor/components/animated-survey-bg"; +import { ColorSurveyBg } from "@/modules/survey/editor/components/color-survey-bg"; +import { UploadImageSurveyBg } from "@/modules/survey/editor/components/image-survey-bg"; +import { ImageFromUnsplashSurveyBg } from "@/modules/survey/editor/components/unsplash-images"; import { TabBar } from "@/modules/ui/components/tab-bar"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useTranslate } from "@tolgee/react"; import { useEffect, useState } from "react"; -import { AnimatedSurveyBg } from "./AnimatedSurveyBg"; -import { ColorSurveyBg } from "./ColorSurveyBg"; -import { UploadImageSurveyBg } from "./ImageSurveyBg"; -import { ImageFromUnsplashSurveyBg } from "./UnsplashImages"; interface SurveyBgSelectorTabProps { handleBgChange: (bg: string, bgType: string) => void; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CardStylingSettings.tsx b/apps/web/modules/ui/components/card-styling-settings/index.tsx similarity index 99% rename from apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CardStylingSettings.tsx rename to apps/web/modules/ui/components/card-styling-settings/index.tsx index 6b82b7e63a..10ff2ba987 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CardStylingSettings.tsx +++ b/apps/web/modules/ui/components/card-styling-settings/index.tsx @@ -7,6 +7,7 @@ import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/ import { Slider } from "@/modules/ui/components/slider"; import { Switch } from "@/modules/ui/components/switch"; import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { Project } from "@prisma/client"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useTranslate } from "@tolgee/react"; import { CheckIcon } from "lucide-react"; @@ -14,7 +15,7 @@ import React from "react"; import { UseFormReturn } from "react-hook-form"; import { cn } from "@formbricks/lib/cn"; import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants"; -import { TProject, TProjectStyling } from "@formbricks/types/project"; +import { TProjectStyling } from "@formbricks/types/project"; import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types"; type CardStylingSettingsProps = { @@ -23,7 +24,7 @@ type CardStylingSettingsProps = { isSettingsPage?: boolean; surveyType?: TSurveyType; disabled?: boolean; - project: TProject; + project: Project; form: UseFormReturn; }; diff --git a/apps/web/modules/ui/components/client-logo/index.tsx b/apps/web/modules/ui/components/client-logo/index.tsx index 574a66d453..83fadaff24 100644 --- a/apps/web/modules/ui/components/client-logo/index.tsx +++ b/apps/web/modules/ui/components/client-logo/index.tsx @@ -1,24 +1,24 @@ "use client"; +import { Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { ArrowUpRight } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { cn } from "@formbricks/lib/cn"; -import { TProject } from "@formbricks/types/project"; interface ClientLogoProps { environmentId?: string; - project: TProject; + projectLogo: Project["logo"] | null; previewSurvey?: boolean; } -export const ClientLogo = ({ environmentId, project, previewSurvey = false }: ClientLogoProps) => { +export const ClientLogo = ({ environmentId, projectLogo, previewSurvey = false }: ClientLogoProps) => { const { t } = useTranslate(); return (
+ style={{ backgroundColor: projectLogo?.bgColor }}> {previewSurvey && environmentId && ( )} - {project.logo?.url ? ( + {projectLogo?.url ? ( ; @@ -19,8 +20,8 @@ interface MediaBackgroundProps { export const MediaBackground: React.FC = ({ children, - project, - survey, + styling, + surveyType, isEditorView = false, isMobilePreview = false, ContentRef, @@ -31,26 +32,7 @@ export const MediaBackground: React.FC = ({ const [backgroundLoaded, setBackgroundLoaded] = useState(false); const [authorDetailsForUnsplash, setAuthorDetailsForUnsplash] = useState({ authorName: "", authorURL: "" }); - // get the background from either the survey or the project styling - const background = useMemo(() => { - // allow style overwrite is disabled from the project - if (!project.styling.allowStyleOverwrite) { - return project.styling.background; - } - - // allow style overwrite is enabled from the project - if (project.styling.allowStyleOverwrite) { - // survey style overwrite is disabled - if (!survey.styling?.overwriteThemeStyling) { - return project.styling.background; - } - - // survey style overwrite is enabled - return survey.styling.background; - } - - return project.styling.background; - }, [project.styling.allowStyleOverwrite, project.styling.background, survey.styling]); + const background = styling.background; useEffect(() => { if (background?.bgType === "animation" && animatedBackgroundRef.current) { @@ -187,7 +169,7 @@ export const MediaBackground: React.FC = ({ className={`relative h-[90%] max-h-[40rem] w-[22rem] overflow-hidden rounded-[3rem] border-[6px] border-slate-400 ${getFilterStyle()}`}> {/* below element is use to create notch for the mobile device mockup */}
- {survey.type === "link" && renderBackground()} + {surveyType === "link" && renderBackground()} {renderContent()}
); diff --git a/apps/web/modules/ui/components/preview-survey/index.tsx b/apps/web/modules/ui/components/preview-survey/index.tsx index e1aa13f4e4..ebbc67aa4e 100644 --- a/apps/web/modules/ui/components/preview-survey/index.tsx +++ b/apps/web/modules/ui/components/preview-survey/index.tsx @@ -4,13 +4,12 @@ import { ClientLogo } from "@/modules/ui/components/client-logo"; import { MediaBackground } from "@/modules/ui/components/media-background"; import { ResetProgressButton } from "@/modules/ui/components/reset-progress-button"; import { SurveyInline } from "@/modules/ui/components/survey"; +import { Environment, Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { Variants, motion } from "framer-motion"; import { ExpandIcon, MonitorIcon, ShrinkIcon, SmartphoneIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { TEnvironment } from "@formbricks/types/environment"; import { TJsFileUploadParams } from "@formbricks/types/js"; -import type { TProject } from "@formbricks/types/project"; import { TProjectStyling } from "@formbricks/types/project"; import { TUploadFileConfig } from "@formbricks/types/storage"; import { TSurvey, TSurveyQuestionId, TSurveyStyling } from "@formbricks/types/surveys/types"; @@ -23,8 +22,8 @@ interface PreviewSurveyProps { survey: TSurvey; questionId?: string | null; previewType?: TPreviewType; - project: TProject; - environment: TEnvironment; + project: Project; + environment: Pick; languageCode: string; onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise; } @@ -251,7 +250,11 @@ export const PreviewSurvey = ({
- + {previewType === "modal" ? (
{!styling.isLogoHidden && ( - + )}
@@ -379,10 +382,14 @@ export const PreviewSurvey = ({ /> ) : ( - +
{!styling.isLogoHidden && ( - + )}
diff --git a/apps/web/modules/ui/components/question-toggle-table/index.tsx b/apps/web/modules/ui/components/question-toggle-table/index.tsx index 87b2ae0aab..28340d3454 100644 --- a/apps/web/modules/ui/components/question-toggle-table/index.tsx +++ b/apps/web/modules/ui/components/question-toggle-table/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { Switch } from "@/modules/ui/components/switch"; import { useTranslate } from "@tolgee/react"; import { diff --git a/apps/web/modules/projects/settings/look/components/theme-styling-preview-survey.tsx b/apps/web/modules/ui/components/theme-styling-preview-survey/index.tsx similarity index 95% rename from apps/web/modules/projects/settings/look/components/theme-styling-preview-survey.tsx rename to apps/web/modules/ui/components/theme-styling-preview-survey/index.tsx index 5a07880976..86842c691f 100644 --- a/apps/web/modules/projects/settings/look/components/theme-styling-preview-survey.tsx +++ b/apps/web/modules/ui/components/theme-styling-preview-survey/index.tsx @@ -5,15 +5,15 @@ import { MediaBackground } from "@/modules/ui/components/media-background"; import { Modal } from "@/modules/ui/components/preview-survey/components/modal"; import { ResetProgressButton } from "@/modules/ui/components/reset-progress-button"; import { SurveyInline } from "@/modules/ui/components/survey"; +import { Project } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { Variants, motion } from "framer-motion"; import { Fragment, useRef, useState } from "react"; -import type { TProject } from "@formbricks/types/project"; import { TSurvey, TSurveyType } from "@formbricks/types/surveys/types"; interface ThemeStylingPreviewSurveyProps { survey: TSurvey; - project: TProject; + project: Project; previewType: TSurveyType; setPreviewType: (type: TSurveyType) => void; } @@ -173,10 +173,14 @@ export const ThemeStylingPreviewSurvey = ({ ) : ( - + {!project.styling?.isLogoHidden && (
- +
)}
{ create: [ { type: "development", - actionClasses: { - create: [ - { - name: "New Session", - description: "Gets fired when a new session is created", - type: "automatic", - }, - ], - }, attributeKeys: { create: [ { @@ -104,15 +95,6 @@ export const createUsersFixture = (page: Page, workerInfo: TestInfo) => { }, { type: "production", - actionClasses: { - create: [ - { - name: "New Session", - description: "Gets fired when a new session is created", - type: "automatic", - }, - ], - }, attributeKeys: { create: [ { diff --git a/apps/web/playwright/js.spec.ts b/apps/web/playwright/js.spec.ts index 9a3c63c47b..547be5d748 100644 --- a/apps/web/playwright/js.spec.ts +++ b/apps/web/playwright/js.spec.ts @@ -78,7 +78,12 @@ test.describe("JS Package Test", async () => { await page.locator("#whenToSendCardTrigger").click(); await page.getByRole("button", { name: "Add action" }).click(); - await page.getByText("New SessionGets fired when a").click(); + + await page.getByRole("button", { name: "Capture new action" }).click(); + await page.getByPlaceholder("E.g. Clicked Download").click(); + await page.getByPlaceholder("E.g. Clicked Download").fill("New Session"); + await page.getByText("Page View").click(); + await page.getByRole("button", { name: "Create action" }).click(); await page.locator("#recontactOptionsCardTrigger").click(); await page.locator("label").filter({ hasText: "Keep showing while conditions" }).click(); diff --git a/packages/lib/actionClass/service.ts b/packages/lib/actionClass/service.ts index c87b09795c..ec2ed407cf 100644 --- a/packages/lib/actionClass/service.ts +++ b/packages/lib/actionClass/service.ts @@ -1,7 +1,7 @@ "use server"; import "server-only"; -import { Prisma } from "@prisma/client"; +import { ActionClass, Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes"; @@ -139,7 +139,7 @@ export const deleteActionClass = async (actionClassId: string): Promise => { +): Promise => { validateInputs([environmentId, ZId], [actionClass, ZActionClassInput]); const { environmentId: _, ...actionClassInput } = actionClass; diff --git a/packages/lib/environment/service.ts b/packages/lib/environment/service.ts index 0b111eaea1..b14b6ba22d 100644 --- a/packages/lib/environment/service.ts +++ b/packages/lib/environment/service.ts @@ -164,15 +164,6 @@ export const createEnvironment = async ( type: environmentInput.type || "development", project: { connect: { id: projectId } }, appSetupCompleted: environmentInput.appSetupCompleted || false, - actionClasses: { - create: [ - { - name: "New Session", - description: "Gets fired when a new session is created", - type: "automatic", - }, - ], - }, attributeKeys: { create: [ { diff --git a/packages/lib/organization/service.ts b/packages/lib/organization/service.ts index 0516a70387..4b07687f02 100644 --- a/packages/lib/organization/service.ts +++ b/packages/lib/organization/service.ts @@ -380,7 +380,8 @@ export const getMonthlyOrganizationResponseCount = reactCache( export const subscribeOrganizationMembersToSurveyResponses = async ( surveyId: string, - createdBy: string + createdBy: string, + organizationId: string ): Promise => { try { const surveyCreator = await prisma.user.findUnique({ @@ -393,6 +394,10 @@ export const subscribeOrganizationMembersToSurveyResponses = async ( throw new ResourceNotFoundError("User", createdBy); } + if (surveyCreator.notificationSettings?.unsubscribedOrganizationIds?.includes(organizationId)) { + return; + } + const defaultSettings = { alert: {}, weeklySummary: {} }; const updatedNotificationSettings: TUserNotificationSettings = { ...defaultSettings, diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index 5a1b0532c4..5102986003 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -663,37 +663,3 @@ export const getResponseCountBySurveyId = reactCache( } )() ); - -export const getIfResponseWithSurveyIdAndEmailExist = reactCache( - async (surveyId: string, email: string): Promise => - cache( - async () => { - validateInputs([surveyId, ZId], [email, ZString]); - - try { - const response = await prisma.response.findFirst({ - where: { - surveyId, - data: { - path: ["verifiedEmail"], - equals: email, - }, - }, - select: { id: true }, - }); - - return !!response; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - }, - [`getIfResponseWithSurveyIdAndEmailExist-${surveyId}-${email}`], - { - tags: [responseCache.tag.bySurveyId(surveyId)], - } - )() -); diff --git a/packages/lib/survey/service.ts b/packages/lib/survey/service.ts index e0ccedb55a..6aa137c92f 100644 --- a/packages/lib/survey/service.ts +++ b/packages/lib/survey/service.ts @@ -1,14 +1,10 @@ import "server-only"; -import { createId } from "@paralleldrive/cuid2"; -import { Prisma } from "@prisma/client"; +import { ActionClass, Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { TActionClass } from "@formbricks/types/action-classes"; import { ZOptionalNumber } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common"; -import { TEnvironment } from "@formbricks/types/environment"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; -import { TProject } from "@formbricks/types/project"; import { TSegment, ZSegmentFilters } from "@formbricks/types/segment"; import { TSurvey, @@ -19,21 +15,15 @@ import { ZSurvey, ZSurveyCreateInput, } from "@formbricks/types/surveys/types"; -import { actionClassCache } from "../actionClass/cache"; import { getActionClasses } from "../actionClass/service"; import { cache } from "../cache"; import { segmentCache } from "../cache/segment"; import { ITEMS_PER_PAGE } from "../constants"; -import { getEnvironment } from "../environment/service"; import { getOrganizationByEnvironmentId, subscribeOrganizationMembersToSurveyResponses, } from "../organization/service"; -import { structuredClone } from "../pollyfills/structuredClone"; import { capturePosthogEnvironmentEvent } from "../posthogServer"; -import { projectCache } from "../project/cache"; -import { getProjectByEnvironmentId } from "../project/service"; -import { responseCache } from "../response/cache"; import { getIsAIEnabled } from "../utils/ai"; import { validateInputs } from "../utils/validate"; import { surveyCache } from "./cache"; @@ -132,7 +122,7 @@ export const selectSurvey = { followUps: true, } satisfies Prisma.SurveySelect; -const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: TActionClass[]) => { +const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => { if (!triggers) return; // check if all the triggers are valid @@ -153,7 +143,7 @@ const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: TAc const handleTriggerUpdates = ( updatedTriggers: TSurvey["triggers"], currentTriggers: TSurvey["triggers"], - actionClasses: TActionClass[] + actionClasses: ActionClass[] ) => { if (!updatedTriggers) return {}; checkTriggersValidity(updatedTriggers, actionClasses); @@ -348,37 +338,6 @@ export const getSurveyCount = reactCache( )() ); -export const getInProgressSurveyCount = reactCache( - async (environmentId: string, filterCriteria?: TSurveyFilterCriteria): Promise => - cache( - async () => { - validateInputs([environmentId, ZId]); - try { - const surveyCount = await prisma.survey.count({ - where: { - environmentId: environmentId, - status: "inProgress", - ...buildWhereClause(filterCriteria), - }, - }); - - return surveyCount; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - console.error(error); - throw new DatabaseError(error.message); - } - - throw error; - } - }, - [`getInProgressSurveyCount-${environmentId}-${JSON.stringify(filterCriteria)}`], - { - tags: [surveyCache.tag.byEnvironmentId(environmentId)], - } - )() -); - export const updateSurvey = async (updatedSurvey: TSurvey): Promise => { validateInputs([updatedSurvey, ZSurvey]); @@ -747,67 +706,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => } }; -export const deleteSurvey = async (surveyId: string) => { - validateInputs([surveyId, ZId]); - - try { - const deletedSurvey = await prisma.survey.delete({ - where: { - id: surveyId, - }, - select: selectSurvey, - }); - - if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) { - const deletedSegment = await prisma.segment.delete({ - where: { - id: deletedSurvey.segment.id, - }, - }); - - if (deletedSegment) { - segmentCache.revalidate({ - id: deletedSegment.id, - environmentId: deletedSurvey.environmentId, - }); - } - } - - responseCache.revalidate({ - surveyId, - environmentId: deletedSurvey.environmentId, - }); - surveyCache.revalidate({ - id: deletedSurvey.id, - environmentId: deletedSurvey.environmentId, - resultShareKey: deletedSurvey.resultShareKey ?? undefined, - }); - - if (deletedSurvey.segment?.id) { - segmentCache.revalidate({ - id: deletedSurvey.segment.id, - environmentId: deletedSurvey.environmentId, - }); - } - - // Revalidate public triggers by actionClassId - deletedSurvey.triggers.forEach((trigger) => { - surveyCache.revalidate({ - actionClassId: trigger.actionClass.id, - }); - }); - - return deletedSurvey; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - console.error(error); - throw new DatabaseError(error.message); - } - - throw error; - } -}; - export const createSurvey = async ( environmentId: string, surveyBody: TSurveyCreateInput @@ -963,7 +861,7 @@ export const createSurvey = async ( }); if (createdBy) { - await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy); + await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id); } await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", { @@ -981,258 +879,6 @@ export const createSurvey = async ( } }; -export const copySurveyToOtherEnvironment = async ( - environmentId: string, - surveyId: string, - targetEnvironmentId: string, - userId: string -) => { - validateInputs([environmentId, ZId], [surveyId, ZId], [targetEnvironmentId, ZId], [userId, ZId]); - - try { - const isSameEnvironment = environmentId === targetEnvironmentId; - - // Fetch required resources - const [existingEnvironment, existingProject, existingSurvey] = await Promise.all([ - getEnvironment(environmentId), - getProjectByEnvironmentId(environmentId), - getSurvey(surveyId), - ]); - - if (!existingEnvironment) throw new ResourceNotFoundError("Environment", environmentId); - if (!existingProject) throw new ResourceNotFoundError("Project", environmentId); - if (!existingSurvey) throw new ResourceNotFoundError("Survey", surveyId); - - let targetEnvironment: TEnvironment | null = null; - let targetProject: TProject | null = null; - - if (isSameEnvironment) { - targetEnvironment = existingEnvironment; - targetProject = existingProject; - } else { - [targetEnvironment, targetProject] = await Promise.all([ - getEnvironment(targetEnvironmentId), - getProjectByEnvironmentId(targetEnvironmentId), - ]); - - if (!targetEnvironment) throw new ResourceNotFoundError("Environment", targetEnvironmentId); - if (!targetProject) throw new ResourceNotFoundError("Project", targetEnvironmentId); - } - - const { - environmentId: _, - createdBy, - id: existingSurveyId, - createdAt, - updatedAt, - ...restExistingSurvey - } = existingSurvey; - const hasLanguages = existingSurvey.languages && existingSurvey.languages.length > 0; - - // Prepare survey data - const surveyData: Prisma.SurveyCreateInput = { - ...restExistingSurvey, - id: createId(), - name: `${existingSurvey.name} (copy)`, - type: existingSurvey.type, - status: "draft", - welcomeCard: structuredClone(existingSurvey.welcomeCard), - questions: structuredClone(existingSurvey.questions), - endings: structuredClone(existingSurvey.endings), - variables: structuredClone(existingSurvey.variables), - hiddenFields: structuredClone(existingSurvey.hiddenFields), - languages: hasLanguages - ? { - create: existingSurvey.languages.map((surveyLanguage) => ({ - language: { - connectOrCreate: { - where: { - projectId_code: { code: surveyLanguage.language.code, projectId: targetProject.id }, - }, - create: { - code: surveyLanguage.language.code, - alias: surveyLanguage.language.alias, - projectId: targetProject.id, - }, - }, - }, - default: surveyLanguage.default, - enabled: surveyLanguage.enabled, - })), - } - : undefined, - triggers: { - create: existingSurvey.triggers.map((trigger): Prisma.SurveyTriggerCreateWithoutSurveyInput => { - const baseActionClassData = { - name: trigger.actionClass.name, - environment: { connect: { id: targetEnvironmentId } }, - description: trigger.actionClass.description, - type: trigger.actionClass.type, - }; - - if (isSameEnvironment) { - return { - actionClass: { connect: { id: trigger.actionClass.id } }, - }; - } else if (trigger.actionClass.type === "code") { - return { - actionClass: { - connectOrCreate: { - where: { - key_environmentId: { key: trigger.actionClass.key!, environmentId: targetEnvironmentId }, - }, - create: { - ...baseActionClassData, - key: trigger.actionClass.key, - }, - }, - }, - }; - } else { - return { - actionClass: { - connectOrCreate: { - where: { - name_environmentId: { - name: trigger.actionClass.name, - environmentId: targetEnvironmentId, - }, - }, - create: { - ...baseActionClassData, - noCodeConfig: trigger.actionClass.noCodeConfig - ? structuredClone(trigger.actionClass.noCodeConfig) - : undefined, - }, - }, - }, - }; - } - }), - }, - environment: { - connect: { - id: targetEnvironmentId, - }, - }, - creator: { - connect: { - id: userId, - }, - }, - surveyClosedMessage: existingSurvey.surveyClosedMessage - ? structuredClone(existingSurvey.surveyClosedMessage) - : Prisma.JsonNull, - singleUse: existingSurvey.singleUse ? structuredClone(existingSurvey.singleUse) : Prisma.JsonNull, - projectOverwrites: existingSurvey.projectOverwrites - ? structuredClone(existingSurvey.projectOverwrites) - : Prisma.JsonNull, - styling: existingSurvey.styling ? structuredClone(existingSurvey.styling) : Prisma.JsonNull, - segment: undefined, - followUps: { - createMany: { - data: existingSurvey.followUps.map((followUp) => ({ - name: followUp.name, - trigger: followUp.trigger, - action: followUp.action, - })), - }, - }, - }; - - // Handle segment - if (existingSurvey.segment) { - if (existingSurvey.segment.isPrivate) { - surveyData.segment = { - create: { - title: surveyData.id!, - isPrivate: true, - filters: existingSurvey.segment.filters, - environment: { connect: { id: targetEnvironmentId } }, - }, - }; - } else if (isSameEnvironment) { - surveyData.segment = { connect: { id: existingSurvey.segment.id } }; - } else { - const existingSegmentInTargetEnvironment = await prisma.segment.findFirst({ - where: { - title: existingSurvey.segment.title, - isPrivate: false, - environmentId: targetEnvironmentId, - }, - }); - - surveyData.segment = { - create: { - title: existingSegmentInTargetEnvironment - ? `${existingSurvey.segment.title}-${Date.now()}` - : existingSurvey.segment.title, - isPrivate: false, - filters: existingSurvey.segment.filters, - environment: { connect: { id: targetEnvironmentId } }, - }, - }; - } - } - - const targetProjectLanguageCodes = targetProject.languages.map((language) => language.code); - const newSurvey = await prisma.survey.create({ - data: surveyData, - select: selectSurvey, - }); - - // Identify newly created action classes - const newActionClasses = newSurvey.triggers.map((trigger) => trigger.actionClass); - - // Revalidate cache only for newly created action classes - for (const actionClass of newActionClasses) { - actionClassCache.revalidate({ - environmentId: actionClass.environmentId, - name: actionClass.name, - id: actionClass.id, - }); - } - - let newLanguageCreated = false; - if (existingSurvey.languages && existingSurvey.languages.length > 0) { - const targetLanguageCodes = newSurvey.languages.map((lang) => lang.language.code); - newLanguageCreated = targetLanguageCodes.length > targetProjectLanguageCodes.length; - } - - // Invalidate caches - if (newLanguageCreated) { - projectCache.revalidate({ id: targetProject.id, environmentId: targetEnvironmentId }); - } - - surveyCache.revalidate({ - id: newSurvey.id, - environmentId: newSurvey.environmentId, - resultShareKey: newSurvey.resultShareKey ?? undefined, - }); - - existingSurvey.triggers.forEach((trigger) => { - surveyCache.revalidate({ - actionClassId: trigger.actionClass.id, - }); - }); - - if (newSurvey.segment) { - segmentCache.revalidate({ - id: newSurvey.segment.id, - environmentId: newSurvey.environmentId, - }); - } - - return newSurvey; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - console.error(error); - throw new DatabaseError(error.message); - } - throw error; - } -}; - export const getSurveyIdByResultShareKey = reactCache( async (resultShareKey: string): Promise => cache( diff --git a/packages/lib/survey/tests/survey.test.ts b/packages/lib/survey/tests/survey.test.ts index 05b26dca87..95555ca362 100644 --- a/packages/lib/survey/tests/survey.test.ts +++ b/packages/lib/survey/tests/survey.test.ts @@ -1,35 +1,17 @@ import { prisma } from "../../__mocks__/database"; -import { mockResponseNote, mockResponseWithMockPerson } from "../../response/tests/__mocks__/data.mock"; import { Prisma } from "@prisma/client"; import { evaluateLogic } from "surveyLogic/utils"; import { beforeEach, describe, expect, it } from "vitest"; import { testInputValidation } from "vitestSetup"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { getSurvey, getSurveyCount, getSurveys, getSurveysByActionClassId, updateSurvey } from "../service"; import { - copySurveyToOtherEnvironment, - createSurvey, - deleteSurvey, - getSurvey, - getSurveyCount, - getSurveys, - getSurveysByActionClassId, - updateSurvey, -} from "../service"; -import { - createSurveyInput, mockActionClass, - mockContactAttributeKey, - mockDisplay, - mockEnvironment, mockId, mockOrganizationOutput, - mockPrismaPerson, - mockProject, mockSurveyOutput, mockSurveyWithLogic, - mockSyncSurveyOutput, mockTransformedSurveyOutput, - mockTransformedSyncSurveyOutput, mockUser, updateSurveyInput, } from "./__mock__/survey.mock"; @@ -325,111 +307,90 @@ describe("Tests for updateSurvey", () => { }); }); -describe("Tests for deleteSurvey", () => { - describe("Happy Path", () => { - it("Deletes a survey successfully", async () => { - prisma.survey.delete.mockResolvedValueOnce(mockSurveyOutput); - const deletedSurvey = await deleteSurvey(mockId); - expect(deletedSurvey).toEqual(mockSurveyOutput); - }); - }); +// describe("Tests for createSurvey", () => { +// beforeEach(() => { +// prisma.actionClass.findMany.mockResolvedValueOnce([mockActionClass]); +// }); - describe("Sad Path", () => { - testInputValidation(deleteSurvey, "123#"); +// describe("Happy Path", () => { +// it("Creates a survey successfully", async () => { +// prisma.survey.create.mockResolvedValueOnce(mockSurveyOutput); +// prisma.organization.findFirst.mockResolvedValueOnce(mockOrganizationOutput); +// prisma.actionClass.findMany.mockResolvedValue([mockActionClass]); +// prisma.user.findMany.mockResolvedValueOnce([ +// { +// ...mockUser, +// twoFactorSecret: null, +// backupCodes: null, +// password: null, +// identityProviderAccountId: null, +// groupId: null, +// role: "engineer", +// }, +// ]); +// prisma.user.update.mockResolvedValueOnce({ +// ...mockUser, +// twoFactorSecret: null, +// backupCodes: null, +// password: null, +// identityProviderAccountId: null, +// groupId: null, +// role: "engineer", +// }); +// const createdSurvey = await createSurvey(mockId, createSurveyInput); +// expect(createdSurvey).toEqual(mockTransformedSurveyOutput); +// }); +// }); - it("should throw an error if there is an unknown error", async () => { - const mockErrorMessage = "Unknown error occurred"; - prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput); - prisma.survey.delete.mockRejectedValue(new Error(mockErrorMessage)); - await expect(deleteSurvey(mockId)).rejects.toThrow(Error); - }); - }); -}); +// describe("Sad Path", () => { +// testInputValidation(createSurvey, "123#", createSurveyInput); -describe("Tests for createSurvey", () => { - beforeEach(() => { - prisma.actionClass.findMany.mockResolvedValueOnce([mockActionClass]); - }); +// it("should throw an error if there is an unknown error", async () => { +// const mockErrorMessage = "Unknown error occurred"; +// prisma.survey.delete.mockRejectedValue(new Error(mockErrorMessage)); +// await expect(createSurvey(mockId, createSurveyInput)).rejects.toThrow(Error); +// }); +// }); +// }); - describe("Happy Path", () => { - it("Creates a survey successfully", async () => { - prisma.survey.create.mockResolvedValueOnce(mockSurveyOutput); - prisma.organization.findFirst.mockResolvedValueOnce(mockOrganizationOutput); - prisma.actionClass.findMany.mockResolvedValue([mockActionClass]); - prisma.user.findMany.mockResolvedValueOnce([ - { - ...mockUser, - twoFactorSecret: null, - backupCodes: null, - password: null, - identityProviderAccountId: null, - groupId: null, - role: "engineer", - }, - ]); - prisma.user.update.mockResolvedValueOnce({ - ...mockUser, - twoFactorSecret: null, - backupCodes: null, - password: null, - identityProviderAccountId: null, - groupId: null, - role: "engineer", - }); - const createdSurvey = await createSurvey(mockId, createSurveyInput); - expect(createdSurvey).toEqual(mockTransformedSurveyOutput); - }); - }); +// describe("Tests for duplicateSurvey", () => { +// beforeEach(() => { +// prisma.actionClass.findMany.mockResolvedValueOnce([mockActionClass]); +// }); - describe("Sad Path", () => { - testInputValidation(createSurvey, "123#", createSurveyInput); +// describe("Happy Path", () => { +// it("Duplicates a survey successfully", async () => { +// prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput); +// prisma.survey.create.mockResolvedValueOnce(mockSurveyOutput); +// // @ts-expect-error +// prisma.environment.findUnique.mockResolvedValueOnce(mockEnvironment); +// // @ts-expect-error +// prisma.project.findFirst.mockResolvedValueOnce(mockProject); +// prisma.actionClass.findFirst.mockResolvedValueOnce(mockActionClass); +// prisma.actionClass.create.mockResolvedValueOnce(mockActionClass); - it("should throw an error if there is an unknown error", async () => { - const mockErrorMessage = "Unknown error occurred"; - prisma.survey.delete.mockRejectedValue(new Error(mockErrorMessage)); - await expect(createSurvey(mockId, createSurveyInput)).rejects.toThrow(Error); - }); - }); -}); +// const createdSurvey = await copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId); +// expect(createdSurvey).toEqual(mockSurveyOutput); +// }); +// }); -describe("Tests for duplicateSurvey", () => { - beforeEach(() => { - prisma.actionClass.findMany.mockResolvedValueOnce([mockActionClass]); - }); +// describe("Sad Path", () => { +// testInputValidation(copySurveyToOtherEnvironment, "123#", "123#", "123#", "123#", "123#"); - describe("Happy Path", () => { - it("Duplicates a survey successfully", async () => { - prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput); - prisma.survey.create.mockResolvedValueOnce(mockSurveyOutput); - // @ts-expect-error - prisma.environment.findUnique.mockResolvedValueOnce(mockEnvironment); - // @ts-expect-error - prisma.project.findFirst.mockResolvedValueOnce(mockProject); - prisma.actionClass.findFirst.mockResolvedValueOnce(mockActionClass); - prisma.actionClass.create.mockResolvedValueOnce(mockActionClass); +// it("Throws ResourceNotFoundError if the survey does not exist", async () => { +// prisma.survey.findUnique.mockRejectedValueOnce(new ResourceNotFoundError("Survey", mockId)); +// await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId)).rejects.toThrow( +// ResourceNotFoundError +// ); +// }); - const createdSurvey = await copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId); - expect(createdSurvey).toEqual(mockSurveyOutput); - }); - }); - - describe("Sad Path", () => { - testInputValidation(copySurveyToOtherEnvironment, "123#", "123#", "123#", "123#", "123#"); - - it("Throws ResourceNotFoundError if the survey does not exist", async () => { - prisma.survey.findUnique.mockRejectedValueOnce(new ResourceNotFoundError("Survey", mockId)); - await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId)).rejects.toThrow( - ResourceNotFoundError - ); - }); - - it("should throw an error if there is an unknown error", async () => { - const mockErrorMessage = "Unknown error occurred"; - prisma.survey.create.mockRejectedValue(new Error(mockErrorMessage)); - await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId)).rejects.toThrow(Error); - }); - }); -}); +// it("should throw an error if there is an unknown error", async () => { +// const mockErrorMessage = "Unknown error occurred"; +// prisma.survey.create.mockRejectedValue(new Error(mockErrorMessage)); +// await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId)).rejects.toThrow(Error); +// }); +// }); +// }); // describe("Tests for getSyncSurveys", () => { // describe("Happy Path", () => { diff --git a/packages/lib/utils/templates.ts b/packages/lib/utils/templates.ts index 8371346f58..cd4763e156 100644 --- a/packages/lib/utils/templates.ts +++ b/packages/lib/utils/templates.ts @@ -15,13 +15,13 @@ export const replaceQuestionPresetPlaceholders = ( newQuestion.headline[defaultLanguageCode] = getLocalizedValue( newQuestion.headline, defaultLanguageCode - ).replace("{{projectName}}", project.name); + ).replace("$[projectName]", project.name); } if (newQuestion.subheader) { newQuestion.subheader[defaultLanguageCode] = getLocalizedValue( newQuestion.subheader, defaultLanguageCode - )?.replace("{{projectName}}", project.name); + )?.replace("$[projectName]", project.name); } return newQuestion; }; @@ -29,7 +29,7 @@ export const replaceQuestionPresetPlaceholders = ( // replace all occurences of projectName with the actual project name in the current template export const replacePresetPlaceholders = (template: TTemplate, project: any) => { const preset = structuredClone(template.preset); - preset.name = preset.name.replace("{{projectName}}", project.name); + preset.name = preset.name.replace("$[projectName]", project.name); preset.questions = preset.questions.map((question) => { return replaceQuestionPresetPlaceholders(question, project); }); diff --git a/packages/react-native/src/components/formbricks.tsx b/packages/react-native/src/components/formbricks.tsx index 53e35b6ac8..b514ea479e 100644 --- a/packages/react-native/src/components/formbricks.tsx +++ b/packages/react-native/src/components/formbricks.tsx @@ -1,8 +1,8 @@ +import React, { useCallback, useEffect, useSyncExternalStore } from "react"; import { SurveyWebView } from "@/components/survey-web-view"; import { Logger } from "@/lib/common/logger"; import { setup } from "@/lib/common/setup"; import { SurveyStore } from "@/lib/survey/store"; -import React, { useCallback, useEffect, useSyncExternalStore } from "react"; interface FormbricksProps { appUrl: string; diff --git a/packages/react-native/src/components/survey-web-view.tsx b/packages/react-native/src/components/survey-web-view.tsx index 673915be5e..3aaf684169 100644 --- a/packages/react-native/src/components/survey-web-view.tsx +++ b/packages/react-native/src/components/survey-web-view.tsx @@ -1,5 +1,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-call -- required */ /* eslint-disable no-console -- debugging*/ +import React, { type JSX, useEffect, useMemo, useRef, useState } from "react"; +import { Modal } from "react-native"; +import { WebView, type WebViewMessageEvent } from "react-native-webview"; +import { FormbricksAPI } from "@formbricks/api"; import { RNConfig } from "@/lib/common/config"; import { StorageAPI } from "@/lib/common/file-upload"; import { Logger } from "@/lib/common/logger"; @@ -11,10 +15,6 @@ import { type TEnvironmentStateSurvey, type TUserState, ZJsRNWebViewOnMessageDat import type { TResponseUpdate } from "@/types/response"; import type { TFileUploadParams, TUploadFileConfig } from "@/types/storage"; import type { SurveyInlineProps } from "@/types/survey"; -import React, { type JSX, useEffect, useMemo, useRef, useState } from "react"; -import { Modal } from "react-native"; -import { WebView, type WebViewMessageEvent } from "react-native-webview"; -import { FormbricksAPI } from "@formbricks/api"; const appConfig = RNConfig.getInstance(); const logger = Logger.getInstance(); diff --git a/packages/react-native/src/lib/common/response-queue.ts b/packages/react-native/src/lib/common/response-queue.ts index 34e333fdc3..35c0d8524c 100644 --- a/packages/react-native/src/lib/common/response-queue.ts +++ b/packages/react-native/src/lib/common/response-queue.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console -- required for logging errors */ +import { FormbricksAPI } from "@formbricks/api"; import { type SurveyState } from "@/lib/survey/state"; import { type TResponseUpdate } from "@/types/response"; -import { FormbricksAPI } from "@formbricks/api"; interface QueueConfig { appUrl: string; diff --git a/packages/react-native/src/lib/common/tests/command-queue.test.ts b/packages/react-native/src/lib/common/tests/command-queue.test.ts index 5609693d6b..89b273f7a7 100644 --- a/packages/react-native/src/lib/common/tests/command-queue.test.ts +++ b/packages/react-native/src/lib/common/tests/command-queue.test.ts @@ -1,7 +1,7 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; import { CommandQueue } from "@/lib/common/command-queue"; import { checkSetup } from "@/lib/common/setup"; import { type Result } from "@/types/error"; -import { beforeEach, describe, expect, test, vi } from "vitest"; // Mock the setup module so we can control checkSetup() vi.mock("@/lib/common/setup", () => ({ diff --git a/packages/react-native/src/lib/common/tests/config.test.ts b/packages/react-native/src/lib/common/tests/config.test.ts index e935969bea..db63fd5633 100644 --- a/packages/react-native/src/lib/common/tests/config.test.ts +++ b/packages/react-native/src/lib/common/tests/config.test.ts @@ -1,9 +1,9 @@ // config.test.ts +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { mockConfig } from "./__mocks__/config.mock"; import { RNConfig, RN_ASYNC_STORAGE_KEY } from "@/lib/common/config"; import type { TConfig, TConfigUpdateInput } from "@/types/config"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; // Define mocks outside of any describe block diff --git a/packages/react-native/src/lib/common/tests/file-upload.test.ts b/packages/react-native/src/lib/common/tests/file-upload.test.ts index ecf4b24b53..4000c69971 100644 --- a/packages/react-native/src/lib/common/tests/file-upload.test.ts +++ b/packages/react-native/src/lib/common/tests/file-upload.test.ts @@ -1,7 +1,7 @@ // file-upload.test.ts +import { beforeEach, describe, expect, test, vi } from "vitest"; import { StorageAPI } from "@/lib/common/file-upload"; import type { TUploadFileConfig } from "@/types/storage"; -import { beforeEach, describe, expect, test, vi } from "vitest"; // A global fetch mock so we can capture fetch calls. // Alternatively, use `vi.stubGlobal("fetch", ...)`. diff --git a/packages/react-native/src/lib/common/tests/logger.test.ts b/packages/react-native/src/lib/common/tests/logger.test.ts index bad468308e..abedede805 100644 --- a/packages/react-native/src/lib/common/tests/logger.test.ts +++ b/packages/react-native/src/lib/common/tests/logger.test.ts @@ -1,6 +1,6 @@ // logger.test.ts -import { Logger } from "@/lib/common/logger"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { Logger } from "@/lib/common/logger"; // adjust import path as needed diff --git a/packages/react-native/src/lib/common/tests/response-queue.test.ts b/packages/react-native/src/lib/common/tests/response-queue.test.ts index e030916f6f..63645bfdff 100644 --- a/packages/react-native/src/lib/common/tests/response-queue.test.ts +++ b/packages/react-native/src/lib/common/tests/response-queue.test.ts @@ -1,4 +1,6 @@ // response-queue.test.ts +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { type FormbricksAPI } from "@formbricks/api"; import { mockAppUrl, mockDisplayId, @@ -11,8 +13,6 @@ import { import { ResponseQueue } from "@/lib/common/response-queue"; import type { SurveyState } from "@/lib/survey/state"; import type { TResponseUpdate } from "@/types/response"; -import { beforeEach, describe, expect, test, vi } from "vitest"; -import { type FormbricksAPI } from "@formbricks/api"; describe("ResponseQueue", () => { let responseQueue: ResponseQueue; diff --git a/packages/react-native/src/lib/common/tests/setup.test.ts b/packages/react-native/src/lib/common/tests/setup.test.ts index 6cf8949302..63759d62f7 100644 --- a/packages/react-native/src/lib/common/tests/setup.test.ts +++ b/packages/react-native/src/lib/common/tests/setup.test.ts @@ -1,3 +1,5 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { RNConfig, RN_ASYNC_STORAGE_KEY } from "@/lib/common/config"; import { addCleanupEventListeners, @@ -10,8 +12,6 @@ import { filterSurveys, isNowExpired } from "@/lib/common/utils"; import { fetchEnvironmentState } from "@/lib/environment/state"; import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state"; import { sendUpdatesToBackend } from "@/lib/user/update"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; // 1) Mock AsyncStorage vi.mock("@react-native-async-storage/async-storage", () => ({ diff --git a/packages/react-native/src/lib/environment/state.ts b/packages/react-native/src/lib/environment/state.ts index 0ef017a5c9..f62349df67 100644 --- a/packages/react-native/src/lib/environment/state.ts +++ b/packages/react-native/src/lib/environment/state.ts @@ -1,10 +1,10 @@ /* eslint-disable no-console -- logging required for error logging */ +import { FormbricksAPI } from "@formbricks/api"; import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { filterSurveys } from "@/lib/common/utils"; import type { TConfigInput, TEnvironmentState } from "@/types/config"; import { type ApiErrorResponse, type Result, err, ok } from "@/types/error"; -import { FormbricksAPI } from "@formbricks/api"; let environmentStateSyncIntervalId: number | null = null; diff --git a/packages/react-native/src/lib/environment/tests/state.test.ts b/packages/react-native/src/lib/environment/tests/state.test.ts index 3b95e4b074..04807d866c 100644 --- a/packages/react-native/src/lib/environment/tests/state.test.ts +++ b/packages/react-native/src/lib/environment/tests/state.test.ts @@ -1,4 +1,6 @@ // state.test.ts +import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { FormbricksAPI } from "@formbricks/api"; import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { filterSurveys } from "@/lib/common/utils"; @@ -8,8 +10,6 @@ import { fetchEnvironmentState, } from "@/lib/environment/state"; import type { TEnvironmentState } from "@/types/config"; -import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { FormbricksAPI } from "@formbricks/api"; // Mock the FormbricksAPI so we can control environment.getState vi.mock("@formbricks/api", () => ({ diff --git a/packages/react-native/src/lib/survey/tests/action.test.ts b/packages/react-native/src/lib/survey/tests/action.test.ts index 9d8aa7ff48..76e93a36d8 100644 --- a/packages/react-native/src/lib/survey/tests/action.test.ts +++ b/packages/react-native/src/lib/survey/tests/action.test.ts @@ -1,10 +1,10 @@ +import { type Mock, beforeEach, describe, expect, test, vi } from "vitest"; import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { shouldDisplayBasedOnPercentage } from "@/lib/common/utils"; import { track, trackAction, triggerSurvey } from "@/lib/survey/action"; import { SurveyStore } from "@/lib/survey/store"; import { type TEnvironmentStateSurvey } from "@/types/config"; -import { type Mock, beforeEach, describe, expect, test, vi } from "vitest"; vi.mock("@/lib/common/config", () => ({ RNConfig: { diff --git a/packages/react-native/src/lib/user/tests/attribute.test.ts b/packages/react-native/src/lib/user/tests/attribute.test.ts index c77122a995..b827c71803 100644 --- a/packages/react-native/src/lib/user/tests/attribute.test.ts +++ b/packages/react-native/src/lib/user/tests/attribute.test.ts @@ -1,6 +1,6 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; import { setAttributes } from "@/lib/user/attribute"; import { UpdateQueue } from "@/lib/user/update-queue"; -import { beforeEach, describe, expect, test, vi } from "vitest"; export const mockAttributes = { name: "John Doe", diff --git a/packages/react-native/src/lib/user/tests/state.test.ts b/packages/react-native/src/lib/user/tests/state.test.ts index 7a2c3dda2f..decc0d3adf 100644 --- a/packages/react-native/src/lib/user/tests/state.test.ts +++ b/packages/react-native/src/lib/user/tests/state.test.ts @@ -1,6 +1,6 @@ +import { type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { RNConfig } from "@/lib/common/config"; import { addUserStateExpiryCheckListener, clearUserStateExpiryCheckListener } from "@/lib/user/state"; -import { type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const mockUserId = "user_123"; diff --git a/packages/react-native/src/lib/user/tests/update-queue.test.ts b/packages/react-native/src/lib/user/tests/update-queue.test.ts index 96fe449695..8dfe6742e2 100644 --- a/packages/react-native/src/lib/user/tests/update-queue.test.ts +++ b/packages/react-native/src/lib/user/tests/update-queue.test.ts @@ -1,8 +1,8 @@ +import { type Mock, beforeEach, describe, expect, test, vi } from "vitest"; import { mockAttributes, mockUserId1, mockUserId2 } from "@/lib/user/tests/__mocks__/update-queue.mock"; import { RNConfig } from "@/lib/common/config"; import { sendUpdates } from "@/lib/user/update"; import { UpdateQueue } from "@/lib/user/update-queue"; -import { type Mock, beforeEach, describe, expect, test, vi } from "vitest"; // Mock dependencies vi.mock("@/lib/common/config", () => ({ diff --git a/packages/react-native/src/lib/user/tests/update.test.ts b/packages/react-native/src/lib/user/tests/update.test.ts index c429e66f40..14c42f1f60 100644 --- a/packages/react-native/src/lib/user/tests/update.test.ts +++ b/packages/react-native/src/lib/user/tests/update.test.ts @@ -1,3 +1,5 @@ +import { type Mock, beforeEach, describe, expect, test, vi } from "vitest"; +import { FormbricksAPI } from "@formbricks/api"; import { mockAppUrl, mockAttributes, @@ -8,8 +10,6 @@ import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { sendUpdates, sendUpdatesToBackend } from "@/lib/user/update"; import { type TUpdates } from "@/types/config"; -import { type Mock, beforeEach, describe, expect, test, vi } from "vitest"; -import { FormbricksAPI } from "@formbricks/api"; vi.mock("@/lib/common/config", () => ({ RNConfig: { diff --git a/packages/react-native/src/lib/user/tests/user.test.ts b/packages/react-native/src/lib/user/tests/user.test.ts index 6e00219b4e..27ccdf2f86 100644 --- a/packages/react-native/src/lib/user/tests/user.test.ts +++ b/packages/react-native/src/lib/user/tests/user.test.ts @@ -1,9 +1,9 @@ +import { type Mock, type MockInstance, beforeEach, describe, expect, test, vi } from "vitest"; import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { setup, tearDown } from "@/lib/common/setup"; import { UpdateQueue } from "@/lib/user/update-queue"; import { logout, setUserId } from "@/lib/user/user"; -import { type Mock, type MockInstance, beforeEach, describe, expect, test, vi } from "vitest"; // Mock dependencies vi.mock("@/lib/common/config", () => ({ diff --git a/packages/react-native/src/lib/user/update.ts b/packages/react-native/src/lib/user/update.ts index b63b7b9648..2237a945e0 100644 --- a/packages/react-native/src/lib/user/update.ts +++ b/packages/react-native/src/lib/user/update.ts @@ -1,10 +1,10 @@ /* eslint-disable no-console -- required for logging errors */ +import { FormbricksAPI } from "@formbricks/api"; import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { filterSurveys } from "@/lib/common/utils"; import { type TUpdates, type TUserState } from "@/types/config"; import { type ApiErrorResponse, type Result, err, ok, okVoid } from "@/types/error"; -import { FormbricksAPI } from "@formbricks/api"; export const sendUpdatesToBackend = async ({ appUrl, diff --git a/packages/react-native/src/types/config.ts b/packages/react-native/src/types/config.ts index 1ff9f88768..ddb6826cb2 100644 --- a/packages/react-native/src/types/config.ts +++ b/packages/react-native/src/types/config.ts @@ -1,8 +1,8 @@ /* eslint-disable import/no-extraneous-dependencies -- required for Prisma types */ -import { type TResponseUpdate, ZResponseUpdate } from "@/types/response"; -import { type TFileUploadParams, ZFileUploadParams } from "@/types/storage"; import type { ActionClass, Language, Project, Segment, Survey, SurveyLanguage } from "@prisma/client"; import { z } from "zod"; +import { type TResponseUpdate, ZResponseUpdate } from "@/types/response"; +import { type TFileUploadParams, ZFileUploadParams } from "@/types/storage"; export type TEnvironmentStateSurvey = Pick< Survey, diff --git a/packages/types/project.ts b/packages/types/project.ts index d2cee4624d..4f41bf5e66 100644 --- a/packages/types/project.ts +++ b/packages/types/project.ts @@ -31,6 +31,7 @@ export const ZLanguage = z.object({ updatedAt: z.date(), code: z.string(), alias: z.string().nullable(), + projectId: z.string().cuid2(), }); export type TLanguage = z.infer;