From c6678a260743d5f674ecab0fa6c6ebcd13d7ebae Mon Sep 17 00:00:00 2001 From: Shubham Palriwala Date: Wed, 4 Oct 2023 01:13:34 +0530 Subject: [PATCH] fix: add authorization for Survey actions (#870) * poc: use server session and api key validation on deletion * feat: use server session and api key validation on deletion and creation * feat: packages/lib/apiKey for apiKey services and auth * shubham/auth-for-api-key * fix: caching * feat: handle authorization for action creation, update, delete * feat: handle authorization for survey creation, updation & deletion * feat: use cached method across and wrapper for authzn check * fix: use cached wrapper & introduce more authzn check for survey services in actions * fix: club caching methods and use authzn errors * feat: add caching in canUserAccessApiKey * feat: add caching in canUserAccessAction and use Authzn error * fix: rename action to actionClass wherever needed * feat: use caching in core method and update hasEnvAccess call * fix: use authzn specific error * fix: use cache getActionClass * fix: make changes * fix: import * fix: import and suggested changes * fix: rename action and use cache tag * feat: use services to create team * fix: atomic services for product & team creation * improve teamUpdateInput * use services in signup process * redirect to prod environment when new product is created * fix signup bug --------- Co-authored-by: Matthias Nannt --- .../(actionsAndAttributes)/actions/actions.ts | 2 +- .../attributes/actions.ts | 2 +- .../environments/[environmentId]/actions.ts | 188 +++++++----------- .../integrations/google-sheets/page.tsx | 2 +- .../integrations/webhooks/page.tsx | 2 +- .../(responseSection)/ResponseSection.tsx | 2 +- .../settings/members/EditTeamName.tsx | 4 +- .../settings/members/actions.ts | 15 +- .../[environmentId]/surveys/SurveyList.tsx | 2 +- .../[environmentId]/surveys/SurveyStarter.tsx | 3 +- .../surveys/[surveyId]/(analysis)/data.ts | 2 +- .../surveys/[surveyId]/SummaryHeader.tsx | 4 +- .../surveys/[surveyId]/edit/SurveyMenuBar.tsx | 6 +- .../surveys/[surveyId]/edit/actions.ts | 24 ++- .../surveys/[surveyId]/edit/page.tsx | 2 +- .../[environmentId]/surveys/actions.ts | 15 +- .../surveys/templates/TemplateList.tsx | 3 +- .../surveys/templates/actions.ts | 15 +- apps/web/app/(app)/onboarding/page.tsx | 18 +- apps/web/app/api/integration/integrations.ts | 2 +- apps/web/app/api/v1/client/displays/route.ts | 2 +- .../v1/client/responses/[responseId]/route.ts | 2 +- apps/web/app/api/v1/client/responses/route.ts | 2 +- apps/web/app/api/v1/js/surveys.ts | 2 +- .../responses/[responseId]/route.ts | 2 +- .../v1/management/surveys/[surveyId]/route.ts | 2 +- .../app/api/v1/management/surveys/route.ts | 2 +- apps/web/app/api/v1/users/route.ts | 123 +++--------- apps/web/app/page.tsx | 7 +- apps/web/app/s/[surveyId]/page.tsx | 2 +- .../components/environments/SecondNavBar.tsx | 2 +- .../shared/SurveyStatusDropdown.tsx | 4 +- apps/web/components/team/CreateTeamModal.tsx | 6 +- apps/web/lib/populate.ts | 28 --- .../[environmentId]/product/index.ts | 27 +-- packages/lib/response/auth.ts | 8 +- packages/lib/services/environment.ts | 165 +++++++-------- packages/lib/services/membership.ts | 20 ++ packages/lib/services/product.ts | 96 +++------ packages/lib/services/profile.ts | 13 ++ packages/lib/services/team.ts | 15 +- packages/lib/survey/auth.ts | 24 +++ .../{services/survey.ts => survey/service.ts} | 17 +- packages/types/v1/actionClasses.ts | 8 + packages/types/v1/attributeClasses.ts | 8 + packages/types/v1/environment.ts | 7 + packages/types/v1/product.ts | 3 +- packages/types/v1/profile.ts | 10 +- packages/types/v1/teams.ts | 9 +- 49 files changed, 441 insertions(+), 488 deletions(-) delete mode 100644 apps/web/lib/populate.ts create mode 100644 packages/lib/survey/auth.ts rename packages/lib/{services/survey.ts => survey/service.ts} (96%) diff --git a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions.ts b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions.ts index 11dd1b590f..0a82437d09 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions.ts @@ -12,7 +12,7 @@ import { getActionCountInLast7Days, getActionCountInLastHour, } from "@formbricks/lib/services/actions"; -import { getSurveysByActionClassId } from "@formbricks/lib/services/survey"; +import { getSurveysByActionClassId } from "@formbricks/lib/survey/service"; import { AuthorizationError } from "@formbricks/types/v1/errors"; export async function deleteActionClassAction(environmentId, actionClassId: string) { diff --git a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/actions.ts b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/actions.ts index 55f9b34315..54bdcabb3e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/actions.ts @@ -1,6 +1,6 @@ "use server"; -import { getSurveysByAttributeClassId } from "@formbricks/lib/services/survey"; +import { getSurveysByAttributeClassId } from "@formbricks/lib/survey/service"; export const GetActiveInactiveSurveysAction = async ( attributeClassId: string diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts index dc4caa403c..1be53741c3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts @@ -1,133 +1,45 @@ "use server"; +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { prisma } from "@formbricks/database"; -import { ResourceNotFoundError } from "@formbricks/types/v1/errors"; -import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; -import { deleteSurvey, getSurvey } from "@formbricks/lib/services/survey"; +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { createMembership } from "@formbricks/lib/services/membership"; +import { createProduct } from "@formbricks/lib/services/product"; +import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/services/team"; +import { canUserAccessSurvey } from "@formbricks/lib/survey/auth"; +import { deleteSurvey, getSurvey } from "@formbricks/lib/survey/service"; +import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/v1/errors"; import { Team } from "@prisma/client"; import { Prisma as prismaClient } from "@prisma/client/"; -import { createProduct } from "@formbricks/lib/services/product"; +import { getServerSession } from "next-auth"; -export async function createTeam(teamName: string, ownerUserId: string): Promise { - const newTeam = await prisma.team.create({ - data: { - name: teamName, - memberships: { - create: { - user: { connect: { id: ownerUserId } }, - role: "owner", - accepted: true, - }, - }, - products: { - create: [ - { - name: "My Product", - environments: { - create: [ - { - type: "production", - eventClasses: { - create: [ - { - name: "New Session", - description: "Gets fired when a new session is created", - type: "automatic", - }, - { - name: "Exit Intent (Desktop)", - description: "A user on Desktop leaves the website with the cursor.", - type: "automatic", - }, - { - name: "50% Scroll", - description: "A user scrolled 50% of the current page", - type: "automatic", - }, - ], - }, - attributeClasses: { - create: [ - { - name: "userId", - description: "The internal ID of the person", - type: "automatic", - }, - { - name: "email", - description: "The email of the person", - type: "automatic", - }, - ], - }, - }, - { - type: "development", - eventClasses: { - create: [ - { - name: "New Session", - description: "Gets fired when a new session is created", - type: "automatic", - }, - { - name: "Exit Intent (Desktop)", - description: "A user on Desktop leaves the website with the cursor.", - type: "automatic", - }, - { - name: "50% Scroll", - description: "A user scrolled 50% of the current page", - type: "automatic", - }, - ], - }, - attributeClasses: { - create: [ - { - name: "userId", - description: "The internal ID of the person", - type: "automatic", - }, - { - name: "email", - description: "The email of the person", - type: "automatic", - }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - include: { - memberships: true, - products: { - include: { - environments: true, - }, - }, - }, +export async function createTeamAction(teamName: string): Promise { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const newTeam = await createTeam({ + name: teamName, }); - const teamId = newTeam?.id; + await createMembership(newTeam.id, session.user.id, { + role: "owner", + accepted: true, + }); - if (teamId) { - fetch(`${WEBAPP_URL}/api/v1/teams/${teamId}/add_demo_product`, { - method: "POST", - headers: { - "x-api-key": INTERNAL_SECRET, - }, - }); - } + await createProduct(newTeam.id, { + name: "My Product", + }); return newTeam; } export async function duplicateSurveyAction(environmentId: string, surveyId: string) { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + const existingSurvey = await getSurvey(surveyId); if (!existingSurvey) { @@ -180,6 +92,24 @@ export async function copyToOtherEnvironmentAction( surveyId: string, targetEnvironmentId: string ) { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess( + session.user.id, + environmentId + ); + if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized"); + + const isAuthorizedToAccessTargetEnvironment = await hasUserEnvironmentAccess( + session.user.id, + targetEnvironmentId + ); + if (!isAuthorizedToAccessTargetEnvironment) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + const existingSurvey = await prisma.survey.findFirst({ where: { id: surveyId, @@ -305,12 +235,32 @@ export async function copyToOtherEnvironmentAction( } export const deleteSurveyAction = async (surveyId: string) => { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + await deleteSurvey(surveyId); }; export const createProductAction = async (environmentId: string, productName: string) => { - const productCreated = await createProduct(environmentId, productName); + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); - const newEnvironment = productCreated.environments[0]; - return newEnvironment; + const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + + const team = await getTeamByEnvironmentId(environmentId); + if (!team) throw new ResourceNotFoundError("Team from environment", environmentId); + + const product = await createProduct(team.id, { + name: productName, + }); + + // get production environment + const productionEnvironment = product.environments.find((environment) => environment.type === "production"); + if (!productionEnvironment) throw new ResourceNotFoundError("Production environment", environmentId); + + return productionEnvironment; }; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx index 6fea3e8ea3..a9bc49678f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx @@ -2,7 +2,7 @@ import GoogleSheetWrapper from "@/app/(app)/environments/[environmentId]/integra import GoBackButton from "@/components/shared/GoBackButton"; import { getSpreadSheets } from "@formbricks/lib/services/googleSheet"; import { getIntegrations } from "@formbricks/lib/services/integrations"; -import { getSurveys } from "@formbricks/lib/services/survey"; +import { getSurveys } from "@formbricks/lib/survey/service"; import { TGoogleSheetIntegration, TGoogleSpreadsheet } from "@formbricks/types/v1/integrations"; import { GOOGLE_SHEETS_CLIENT_ID, diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.tsx index 41ed71d2b8..3c4839696d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.tsx @@ -4,7 +4,7 @@ import WebhookRowData from "@/app/(app)/environments/[environmentId]/integration import WebhookTable from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTable"; import WebhookTableHeading from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTableHeading"; import GoBackButton from "@/components/shared/GoBackButton"; -import { getSurveys } from "@formbricks/lib/services/survey"; +import { getSurveys } from "@formbricks/lib/survey/service"; import { getWebhooks } from "@formbricks/lib/services/webhook"; import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/services/environment"; diff --git a/apps/web/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseSection.tsx b/apps/web/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseSection.tsx index 60f73bced7..969d094116 100644 --- a/apps/web/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseSection.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseSection.tsx @@ -1,6 +1,6 @@ import ResponseTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseTimeline"; +import { getSurveys } from "@formbricks/lib/survey/service"; import { getResponsesByPersonId } from "@formbricks/lib/response/service"; -import { getSurveys } from "@formbricks/lib/services/survey"; import { TEnvironment } from "@formbricks/types/v1/environment"; import { TResponseWithSurvey } from "@formbricks/types/v1/responses"; import { TSurvey } from "@formbricks/types/v1/surveys"; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/members/EditTeamName.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/members/EditTeamName.tsx index 87320cb6ab..dad6c6800c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/members/EditTeamName.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/members/EditTeamName.tsx @@ -1,6 +1,6 @@ "use client"; -import { updateTeamAction } from "@/app/(app)/environments/[environmentId]/settings/members/actions"; +import { updateTeamNameAction } from "@/app/(app)/environments/[environmentId]/settings/members/actions"; import { TTeam } from "@formbricks/types/v1/teams"; import { Button, Input, Label } from "@formbricks/ui"; import { useRouter } from "next/navigation"; @@ -43,7 +43,7 @@ export default function EditTeamName({ team }: TEditTeamNameProps) { const handleUpdateTeamName: SubmitHandler = async (data) => { try { setIsUpdatingTeam(true); - await updateTeamAction(team.id, data); + await updateTeamNameAction(team.id, data.name); setIsUpdatingTeam(false); toast.success("Team name updated successfully."); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/members/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/members/actions.ts index d101e9fc00..c9277e84a1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/members/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/members/actions.ts @@ -20,13 +20,22 @@ import { import { deleteTeam, updateTeam } from "@formbricks/lib/services/team"; import { TInviteUpdateInput } from "@formbricks/types/v1/invites"; import { TMembershipRole, TMembershipUpdateInput } from "@formbricks/types/v1/memberships"; -import { TTeamUpdateInput } from "@formbricks/types/v1/teams"; import { getServerSession } from "next-auth"; import { hasTeamAccess, hasTeamAuthority, hasTeamOwnership, isOwner } from "@formbricks/lib/auth"; import { INVITE_DISABLED } from "@formbricks/lib/constants"; -export const updateTeamAction = async (teamId: string, data: TTeamUpdateInput) => { - return await updateTeam(teamId, data); +export const updateTeamNameAction = async (teamId: string, teamName: string) => { + const session = await getServerSession(authOptions); + if (!session) { + throw new AuthenticationError("Not authenticated"); + } + + const isUserAuthorized = await hasTeamAuthority(session.user.id, teamId); + if (!isUserAuthorized) { + throw new AuthenticationError("Not authorized"); + } + + return await updateTeam(teamId, { name: teamName }); }; export const updateMembershipAction = async ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx index bf5fcf60be..c75e81083d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx @@ -5,7 +5,7 @@ import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator"; import { SURVEY_BASE_URL } from "@formbricks/lib/constants"; import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment"; import { getProductByEnvironmentId } from "@formbricks/lib/services/product"; -import { getSurveys } from "@formbricks/lib/services/survey"; +import { getSurveys } from "@formbricks/lib/survey/service"; import type { TEnvironment } from "@formbricks/types/v1/environment"; import { Badge } from "@formbricks/ui"; import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyStarter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyStarter.tsx index 7878b3b755..2d94b0003a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyStarter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyStarter.tsx @@ -4,6 +4,7 @@ import TemplateList from "@/app/(app)/environments/[environmentId]/surveys/templ import LoadingSpinner from "@/components/shared/LoadingSpinner"; import type { TEnvironment } from "@formbricks/types/v1/environment"; import type { TProduct } from "@formbricks/types/v1/product"; +import { TSurveyInput } from "@formbricks/types/v1/surveys"; import { TTemplate } from "@formbricks/types/v1/templates"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -28,7 +29,7 @@ export default function SurveyStarter({ ...template.preset, type: surveyType, autoComplete, - }; + } as Partial; try { const survey = await createSurveyAction(environmentId, augmentedTemplate); router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data.ts index bd86c900a1..479538c7a4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data.ts @@ -1,7 +1,7 @@ import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { getSurveyWithAnalytics } from "@formbricks/lib/survey/service"; import { getSurveyResponses } from "@formbricks/lib/response/service"; -import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey"; import { getTeamByEnvironmentId } from "@formbricks/lib/services/team"; export const getAnalysisData = async (surveyId: string, environmentId: string) => { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/SummaryHeader.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/SummaryHeader.tsx index 986d2d165e..0416b6b497 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/SummaryHeader.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/SummaryHeader.tsx @@ -23,7 +23,7 @@ import LinkSurveyShareButton from "@/app/(app)/environments/[environmentId]/surv import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown"; import { TEnvironment } from "@formbricks/types/v1/environment"; import { TProduct } from "@formbricks/types/v1/product"; -import { surveyMutateAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions"; +import { updateSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions"; interface SummaryHeaderProps { surveyId: string; @@ -108,7 +108,7 @@ const SummaryHeader = ({ value={survey.status} onValueChange={(value) => { const castedValue = value as "draft" | "inProgress" | "paused" | "completed"; - surveyMutateAction({ ...survey, status: castedValue }) + updateSurveyAction({ ...survey, status: castedValue }) .then(() => { toast.success( value === "inProgress" diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx index 7f3e764e16..b0f9bd0378 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx @@ -14,7 +14,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { validateQuestion } from "./Validation"; -import { deleteSurveyAction, surveyMutateAction } from "./actions"; +import { deleteSurveyAction, updateSurveyAction } from "./actions"; interface SurveyMenuBarProps { localSurvey: TSurveyWithAnalytics; @@ -143,7 +143,7 @@ export default function SurveyMenuBar({ } try { - await surveyMutateAction({ ...strippedSurvey }); + await updateSurveyAction({ ...strippedSurvey }); router.refresh(); setIsMutatingSurvey(false); toast.success("Changes saved."); @@ -242,7 +242,7 @@ export default function SurveyMenuBar({ if (!validateSurvey(localSurvey)) { return; } - await surveyMutateAction({ ...localSurvey, status: "inProgress" }); + await updateSurveyAction({ ...localSurvey, status: "inProgress" }); router.refresh(); setIsMutatingSurvey(false); router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary?success=true`); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts index ebd4ce8307..97a0304f2e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts @@ -1,12 +1,28 @@ "use server"; import { TSurvey } from "@formbricks/types/v1/surveys"; -import { deleteSurvey, updateSurvey } from "@formbricks/lib/services/survey"; +import { deleteSurvey, updateSurvey } from "@formbricks/lib/survey/service"; +import { canUserAccessSurvey } from "@formbricks/lib/survey/auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { getServerSession } from "next-auth"; +import { AuthorizationError } from "@formbricks/types/v1/errors"; + +export async function updateSurveyAction(survey: TSurvey): Promise { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessSurvey(session.user.id, survey.id); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); -export async function surveyMutateAction(survey: TSurvey): Promise { return await updateSurvey(survey); } -export async function deleteSurveyAction(surveyId: string) { +export const deleteSurveyAction = async (surveyId: string) => { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + await deleteSurvey(surveyId); -} +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx index 9376c15f6a..fd18a81945 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx @@ -2,7 +2,7 @@ export const revalidate = REVALIDATION_INTERVAL; import React from "react"; import { FORMBRICKS_ENCRYPTION_KEY, REVALIDATION_INTERVAL } from "@formbricks/lib/constants"; import SurveyEditor from "./SurveyEditor"; -import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey"; +import { getSurveyWithAnalytics } from "@formbricks/lib/survey/service"; import { getProductByEnvironmentId } from "@formbricks/lib/services/product"; import { getEnvironment } from "@formbricks/lib/services/environment"; import { getActionClasses } from "@formbricks/lib/actionClass/service"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts index bd3454703c..31240985e5 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts @@ -1,7 +1,18 @@ "use server"; -import { createSurvey } from "@formbricks/lib/services/survey"; +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { getServerSession } from "next-auth"; +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { createSurvey } from "@formbricks/lib/survey/service"; +import { AuthorizationError } from "@formbricks/types/v1/errors"; +import { TSurvey } from "@formbricks/types/v1/surveys"; + +export async function createSurveyAction(environmentId: string, surveyBody: Partial) { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); -export async function createSurveyAction(environmentId: string, surveyBody: any) { return await createSurvey(environmentId, surveyBody); } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateList.tsx index 96bfdac84d..832287750f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateList.tsx @@ -22,6 +22,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { createSurveyAction } from "./actions"; import { customSurvey, templates } from "./templates"; +import { TSurveyInput } from "@formbricks/types/v1/surveys"; type TemplateList = { environmentId: string; @@ -77,7 +78,7 @@ export default function TemplateList({ ...activeTemplate.preset, type: surveyType, autoComplete, - }; + } as Partial; const survey = await createSurveyAction(environmentId, augmentedTemplate); router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/actions.ts index bd3454703c..33cb980756 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/actions.ts @@ -1,7 +1,18 @@ "use server"; -import { createSurvey } from "@formbricks/lib/services/survey"; +import { createSurvey } from "@formbricks/lib/survey/service"; +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { getServerSession } from "next-auth"; +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { AuthorizationError } from "@formbricks/types/v1/errors"; +import { TSurvey } from "@formbricks/types/v1/surveys"; + +export async function createSurveyAction(environmentId: string, surveyBody: Partial) { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); -export async function createSurveyAction(environmentId: string, surveyBody: any) { return await createSurvey(environmentId, surveyBody); } diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 0997cd6f38..56796e13e4 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -1,23 +1,25 @@ export const revalidate = REVALIDATION_INTERVAL; -import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants"; import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants"; +import { getFirstEnvironmentByUserId } from "@formbricks/lib/services/environment"; +import { getProductByEnvironmentId } from "@formbricks/lib/services/product"; +import { getProfile } from "@formbricks/lib/services/profile"; import { getServerSession } from "next-auth"; import Onboarding from "./components/Onboarding"; -import { getEnvironmentByUser } from "@formbricks/lib/services/environment"; -import { getProfile } from "@formbricks/lib/services/profile"; -import { ErrorComponent } from "@formbricks/ui"; -import { getProductByEnvironmentId } from "@formbricks/lib/services/product"; export default async function OnboardingPage() { const session = await getServerSession(authOptions); - const environment = await getEnvironmentByUser(session?.user); + if (!session) { + throw new Error("No session found"); + } + const environment = await getFirstEnvironmentByUserId(session?.user.id); const profile = await getProfile(session?.user.id!); const product = await getProductByEnvironmentId(environment?.id!); if (!environment || !profile || !product) { - return ; + throw new Error("Failed to get environment, profile, or product"); } - return ; + return ; } diff --git a/apps/web/app/api/integration/integrations.ts b/apps/web/app/api/integration/integrations.ts index 3ffbc383d7..c28c768867 100644 --- a/apps/web/app/api/integration/integrations.ts +++ b/apps/web/app/api/integration/integrations.ts @@ -1,5 +1,5 @@ import { writeData } from "@formbricks/lib/services/googleSheet"; -import { getSurvey } from "@formbricks/lib/services/survey"; +import { getSurvey } from "@formbricks/lib/survey/service"; import { TGoogleSheetIntegration, TIntegration } from "@formbricks/types/v1/integrations"; import { TPipelineInput } from "@formbricks/types/v1/pipelines"; diff --git a/apps/web/app/api/v1/client/displays/route.ts b/apps/web/app/api/v1/client/displays/route.ts index e51d6639af..7d46e99a8e 100644 --- a/apps/web/app/api/v1/client/displays/route.ts +++ b/apps/web/app/api/v1/client/displays/route.ts @@ -3,7 +3,7 @@ import { transformErrorToDetails } from "@/lib/api/validator"; import { InvalidInputError } from "@formbricks/types/v1/errors"; import { capturePosthogEvent } from "@formbricks/lib/posthogServer"; import { createDisplay } from "@formbricks/lib/services/displays"; -import { getSurvey } from "@formbricks/lib/services/survey"; +import { getSurvey } from "@formbricks/lib/survey/service"; import { getTeamDetails } from "@formbricks/lib/services/teamDetails"; import { TDisplay, ZDisplayInput } from "@formbricks/types/v1/displays"; import { NextResponse } from "next/server"; diff --git a/apps/web/app/api/v1/client/responses/[responseId]/route.ts b/apps/web/app/api/v1/client/responses/[responseId]/route.ts index aba81b2fb4..59edb5b4cb 100644 --- a/apps/web/app/api/v1/client/responses/[responseId]/route.ts +++ b/apps/web/app/api/v1/client/responses/[responseId]/route.ts @@ -2,8 +2,8 @@ import { responses } from "@/lib/api/response"; import { transformErrorToDetails } from "@/lib/api/validator"; import { sendToPipeline } from "@/lib/pipelines"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/v1/errors"; +import { getSurvey } from "@formbricks/lib/survey/service"; import { updateResponse } from "@formbricks/lib/response/service"; -import { getSurvey } from "@formbricks/lib/services/survey"; import { ZResponseUpdateInput } from "@formbricks/types/v1/responses"; import { NextResponse } from "next/server"; diff --git a/apps/web/app/api/v1/client/responses/route.ts b/apps/web/app/api/v1/client/responses/route.ts index aac5cbbabc..0def24c540 100644 --- a/apps/web/app/api/v1/client/responses/route.ts +++ b/apps/web/app/api/v1/client/responses/route.ts @@ -3,8 +3,8 @@ import { transformErrorToDetails } from "@/lib/api/validator"; import { sendToPipeline } from "@/lib/pipelines"; import { InvalidInputError } from "@formbricks/types/v1/errors"; import { capturePosthogEvent } from "@formbricks/lib/posthogServer"; +import { getSurvey } from "@formbricks/lib/survey/service"; import { createResponse } from "@formbricks/lib/response/service"; -import { getSurvey } from "@formbricks/lib/services/survey"; import { getTeamDetails } from "@formbricks/lib/services/teamDetails"; import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/v1/responses"; import { NextResponse } from "next/server"; diff --git a/apps/web/app/api/v1/js/surveys.ts b/apps/web/app/api/v1/js/surveys.ts index 4b09a5502e..bd0a77f446 100644 --- a/apps/web/app/api/v1/js/surveys.ts +++ b/apps/web/app/api/v1/js/surveys.ts @@ -1,5 +1,5 @@ import { prisma } from "@formbricks/database"; -import { selectSurvey } from "@formbricks/lib/services/survey"; +import { selectSurvey } from "@formbricks/lib/survey/service"; import { TPerson } from "@formbricks/types/v1/people"; import { TSurvey } from "@formbricks/types/v1/surveys"; import { unstable_cache } from "next/cache"; diff --git a/apps/web/app/api/v1/management/responses/[responseId]/route.ts b/apps/web/app/api/v1/management/responses/[responseId]/route.ts index 7941a82eea..4158e05bc0 100644 --- a/apps/web/app/api/v1/management/responses/[responseId]/route.ts +++ b/apps/web/app/api/v1/management/responses/[responseId]/route.ts @@ -4,7 +4,7 @@ import { transformErrorToDetails } from "@/lib/api/validator"; import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service"; import { TResponse, ZResponseUpdateInput } from "@formbricks/types/v1/responses"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; -import { getSurvey } from "@formbricks/lib/services/survey"; +import { getSurvey } from "@formbricks/lib/survey/service"; import { authenticateRequest } from "@/app/api/v1/auth"; import { handleErrorResponse } from "@/app/api/v1/auth"; 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 c25e2ec9a1..cc096f9caa 100644 --- a/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts +++ b/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts @@ -1,6 +1,6 @@ import { responses } from "@/lib/api/response"; import { NextResponse } from "next/server"; -import { getSurvey, updateSurvey, deleteSurvey } from "@formbricks/lib/services/survey"; +import { getSurvey, updateSurvey, deleteSurvey } from "@formbricks/lib/survey/service"; import { TSurvey, ZSurveyInput } from "@formbricks/types/v1/surveys"; import { transformErrorToDetails } from "@/lib/api/validator"; import { authenticateRequest } from "@/app/api/v1/auth"; diff --git a/apps/web/app/api/v1/management/surveys/route.ts b/apps/web/app/api/v1/management/surveys/route.ts index 1ced6ec5d9..4a7d3eb939 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 { responses } from "@/lib/api/response"; import { authenticateRequest } from "@/app/api/v1/auth"; import { NextResponse } from "next/server"; import { transformErrorToDetails } from "@/lib/api/validator"; -import { createSurvey, getSurveys } from "@formbricks/lib/services/survey"; +import { createSurvey, getSurveys } from "@formbricks/lib/survey/service"; import { ZSurveyInput } from "@formbricks/types/v1/surveys"; import { DatabaseError } from "@formbricks/types/v1/errors"; diff --git a/apps/web/app/api/v1/users/route.ts b/apps/web/app/api/v1/users/route.ts index 16fefd3f2c..b4352157eb 100644 --- a/apps/web/app/api/v1/users/route.ts +++ b/apps/web/app/api/v1/users/route.ts @@ -1,16 +1,13 @@ import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/lib/email"; -import { verifyInviteToken } from "@formbricks/lib/jwt"; -import { populateEnvironment } from "@/lib/populate"; import { prisma } from "@formbricks/database"; +import { EMAIL_VERIFICATION_DISABLED, INVITE_DISABLED, SIGNUP_ENABLED } from "@formbricks/lib/constants"; +import { verifyInviteToken } from "@formbricks/lib/jwt"; +import { deleteInvite } from "@formbricks/lib/services/invite"; +import { createMembership } from "@formbricks/lib/services/membership"; +import { createProduct } from "@formbricks/lib/services/product"; +import { createProfile } from "@formbricks/lib/services/profile"; +import { createTeam } from "@formbricks/lib/services/team"; import { NextResponse } from "next/server"; -import { Prisma } from "@prisma/client"; -import { - EMAIL_VERIFICATION_DISABLED, - INTERNAL_SECRET, - INVITE_DISABLED, - SIGNUP_ENABLED, - WEBAPP_URL, -} from "@formbricks/lib/constants"; export async function POST(request: Request) { let { inviteToken, ...user } = await request.json(); @@ -22,7 +19,6 @@ export async function POST(request: Request) { let inviteId; try { - let data: Prisma.UserCreateArgs; let invite; if (inviteToken) { @@ -40,92 +36,35 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Invalid invite ID" }, { status: 400 }); } - data = { - data: { - ...user, - memberships: { - create: { - accepted: true, - role: invite.role, - team: { - connect: { - id: invite.teamId, - }, - }, - }, - }, - }, - }; - } else { - data = { - data: { - ...user, - memberships: { - create: [ - { - accepted: true, - role: "owner", - team: { - create: { - name: `${user.name}'s Team`, - products: { - create: [ - { - name: "My Product", - environments: { - create: [ - { - type: "production", - ...populateEnvironment, - }, - { - type: "development", - ...populateEnvironment, - }, - ], - }, - }, - ], - }, - }, - }, - }, - ], - }, - }, - }; - } + // create a user and assign him to the team - type UserWithMemberships = Prisma.UserGetPayload<{ include: { memberships: true } }>; - - const userData = (await prisma.user.create({ - ...data, - include: { - memberships: true, - }, - // TODO: This is a hack to get the correct types (casting), we should find a better way to do this - })) as UserWithMemberships; - - const teamId = userData.memberships[0].teamId; - - if (teamId) { - fetch(`${WEBAPP_URL}/api/v1/teams/${teamId}/add_demo_product`, { - method: "POST", - headers: { - "x-api-key": INTERNAL_SECRET, - }, + const profile = await createProfile(user); + await createMembership(invite.teamId, profile.id, { + accepted: true, + role: invite.role, }); - } - if (inviteId) { - sendInviteAcceptedEmail(invite.creator.name, user.name, invite.creator.email); - await prisma.invite.delete({ where: { id: inviteId } }); - } + if (!EMAIL_VERIFICATION_DISABLED) { + await sendVerificationEmail(profile); + } - if (!EMAIL_VERIFICATION_DISABLED) { - await sendVerificationEmail(userData); + await sendInviteAcceptedEmail(invite.creator.name, user.name, invite.creator.email); + await deleteInvite(inviteId); + + return NextResponse.json(profile); + } else { + const team = await createTeam({ + name: `${user.name}'s Team`, + }); + await createProduct(team.id, { name: "My Product" }); + const profile = await createProfile(user); + await createMembership(team.id, profile.id, { role: "owner", accepted: true }); + + if (!EMAIL_VERIFICATION_DISABLED) { + await sendVerificationEmail(profile); + } + return NextResponse.json(profile); } - return NextResponse.json(userData); } catch (e) { if (e.code === "P2002") { return NextResponse.json( diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 30ac126219..e6489cb9d8 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,6 +1,6 @@ import ClientLogout from "@/app/ClientLogout"; import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { getEnvironmentByUser } from "@formbricks/lib/services/environment"; +import { getFirstEnvironmentByUserId } from "@formbricks/lib/services/environment"; import type { Session } from "next-auth"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; @@ -18,7 +18,10 @@ export default async function Home() { let environment; try { - environment = await getEnvironmentByUser(session?.user); + environment = await getFirstEnvironmentByUserId(session?.user.id); + if (!environment) { + throw new Error("No environment found"); + } } catch (error) { console.error("error getting environment", error); } diff --git a/apps/web/app/s/[surveyId]/page.tsx b/apps/web/app/s/[surveyId]/page.tsx index 1b7ff9bb5c..43a4940dc3 100644 --- a/apps/web/app/s/[surveyId]/page.tsx +++ b/apps/web/app/s/[surveyId]/page.tsx @@ -5,7 +5,7 @@ import SurveyInactive from "@/app/s/[surveyId]/SurveyInactive"; import { REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants"; import { getOrCreatePersonByUserId } from "@formbricks/lib/services/person"; import { getProductByEnvironmentId } from "@formbricks/lib/services/product"; -import { getSurvey } from "@formbricks/lib/services/survey"; +import { getSurvey } from "@formbricks/lib/survey/service"; import { getEmailVerificationStatus } from "./helpers"; import { checkValidity } from "@/app/s/[surveyId]/prefilling"; import { notFound } from "next/navigation"; diff --git a/apps/web/components/environments/SecondNavBar.tsx b/apps/web/components/environments/SecondNavBar.tsx index ec553fcf10..f825f92710 100644 --- a/apps/web/components/environments/SecondNavBar.tsx +++ b/apps/web/components/environments/SecondNavBar.tsx @@ -2,7 +2,7 @@ import { cn } from "@formbricks/lib/cn"; import SurveyNavBarName from "@/components/shared/SurveyNavBarName"; import Link from "next/link"; import { getProductByEnvironmentId } from "@formbricks/lib/services/product"; -import { getSurvey } from "@formbricks/lib/services/survey"; +import { getSurvey } from "@formbricks/lib/survey/service"; interface SecondNavbarProps { tabs: { id: string; label: string; href: string; icon?: React.ReactNode }[]; diff --git a/apps/web/components/shared/SurveyStatusDropdown.tsx b/apps/web/components/shared/SurveyStatusDropdown.tsx index 63e508b6c5..74941fa02a 100644 --- a/apps/web/components/shared/SurveyStatusDropdown.tsx +++ b/apps/web/components/shared/SurveyStatusDropdown.tsx @@ -1,6 +1,6 @@ "use client"; -import { surveyMutateAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions"; +import { updateSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions"; import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator"; import { TEnvironment } from "@formbricks/types/v1/environment"; import { TSurvey } from "@formbricks/types/v1/surveys"; @@ -44,7 +44,7 @@ export default function SurveyStatusDropdown({ disabled={isStatusChangeDisabled} onValueChange={(value) => { const castedValue = value as "draft" | "inProgress" | "paused" | "completed"; - surveyMutateAction({ ...survey, status: castedValue }) + updateSurveyAction({ ...survey, status: castedValue }) .then(() => { toast.success( value === "inProgress" diff --git a/apps/web/components/team/CreateTeamModal.tsx b/apps/web/components/team/CreateTeamModal.tsx index 750f5c6eeb..b6d48ddf81 100644 --- a/apps/web/components/team/CreateTeamModal.tsx +++ b/apps/web/components/team/CreateTeamModal.tsx @@ -1,6 +1,5 @@ -import { createTeam } from "@/app/(app)/environments/[environmentId]/actions"; +import { createTeamAction } from "@/app/(app)/environments/[environmentId]/actions"; import Modal from "@/components/shared/Modal"; -import { useProfile } from "@/lib/profile"; import { Button, Input, Label } from "@formbricks/ui"; import { PlusCircleIcon } from "@heroicons/react/24/outline"; import { useRouter } from "next/navigation"; @@ -15,13 +14,12 @@ interface CreateTeamModalProps { export default function CreateTeamModal({ open, setOpen }: CreateTeamModalProps) { const router = useRouter(); - const { profile } = useProfile(); const [loading, setLoading] = useState(false); const { register, handleSubmit } = useForm(); const submitTeam = async (data) => { setLoading(true); - const newTeam = await createTeam(data.name, (profile as any).id); + const newTeam = await createTeamAction(data.name); toast.success("Team created successfully!"); router.push(`/teams/${newTeam.id}`); diff --git a/apps/web/lib/populate.ts b/apps/web/lib/populate.ts deleted file mode 100644 index e36684173d..0000000000 --- a/apps/web/lib/populate.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { EventType } from "@prisma/client"; -export const populateEnvironment = { - eventClasses: { - create: [ - { - name: "New Session", - description: "Gets fired when a new session is created", - type: EventType.automatic, - }, - { - name: "Exit Intent (Desktop)", - description: "A user on Desktop leaves the website with the cursor.", - type: EventType.automatic, - }, - { - name: "50% Scroll", - description: "A user scrolled 50% of the current page", - type: EventType.automatic, - }, - ], - }, - attributeClasses: { - create: [ - { name: "userId", description: "The internal ID of the person", type: EventType.automatic }, - { name: "email", description: "The email of the person", type: EventType.automatic }, - ], - }, -}; diff --git a/apps/web/pages/api/v1/environments/[environmentId]/product/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/product/index.ts index d261973c77..1c4043240d 100644 --- a/apps/web/pages/api/v1/environments/[environmentId]/product/index.ts +++ b/apps/web/pages/api/v1/environments/[environmentId]/product/index.ts @@ -1,7 +1,6 @@ import { getSessionUser, hasEnvironmentAccess } from "@/lib/api/apiHelper"; import { prisma } from "@formbricks/database"; -import { EnvironmentType } from "@prisma/client"; -import { populateEnvironment } from "@/lib/populate"; +import { createProduct } from "@formbricks/lib/services/product"; import type { NextApiRequest, NextApiResponse } from "next"; export default async function handle(req: NextApiRequest, res: NextApiResponse) { @@ -93,29 +92,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) } // Create a new product and associate it with the current team - const newProduct = await prisma.product.create({ - data: { - name, - team: { - connect: { id: environment.product.teamId }, - }, - environments: { - create: [ - { - type: EnvironmentType.production, - ...populateEnvironment, - }, - { - type: EnvironmentType.development, - ...populateEnvironment, - }, - ], - }, - }, - select: { - environments: true, - }, - }); + const newProduct = await createProduct(environment.product.teamId, { name }); const firstEnvironment = newProduct.environments[0]; res.json(firstEnvironment); diff --git a/packages/lib/response/auth.ts b/packages/lib/response/auth.ts index f4adfe0858..513354f212 100644 --- a/packages/lib/response/auth.ts +++ b/packages/lib/response/auth.ts @@ -1,12 +1,12 @@ import "server-only"; import { ZId } from "@formbricks/types/v1/environment"; -import { validateInputs } from "../utils/validate"; -import { hasUserEnvironmentAccess } from "../environment/auth"; -import { getResponse, getResponseCacheTag } from "./service"; import { unstable_cache } from "next/cache"; -import { getSurvey } from "../services/survey"; import { SERVICES_REVALIDATION_INTERVAL } from "../constants"; +import { hasUserEnvironmentAccess } from "../environment/auth"; +import { getSurvey } from "../survey/service"; +import { validateInputs } from "../utils/validate"; +import { getResponse, getResponseCacheTag } from "./service"; export const canUserAccessResponse = async (userId: string, responseId: string): Promise => await unstable_cache( diff --git a/packages/lib/services/environment.ts b/packages/lib/services/environment.ts index 87714c5f19..1657d3d6a8 100644 --- a/packages/lib/services/environment.ts +++ b/packages/lib/services/environment.ts @@ -1,15 +1,24 @@ import "server-only"; import { prisma } from "@formbricks/database"; -import { z } from "zod"; -import { Prisma, EnvironmentType } from "@prisma/client"; +import type { + TEnvironment, + TEnvironmentCreateInput, + TEnvironmentUpdateInput, +} from "@formbricks/types/v1/environment"; +import { + ZEnvironment, + ZEnvironmentCreateInput, + ZEnvironmentUpdateInput, + ZId, +} from "@formbricks/types/v1/environment"; import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/v1/errors"; -import type { TEnvironment, TEnvironmentId, TEnvironmentUpdateInput } from "@formbricks/types/v1/environment"; -import { populateEnvironment } from "../utils/createDemoProductHelpers"; -import { ZEnvironment, ZEnvironmentUpdateInput, ZId } from "@formbricks/types/v1/environment"; -import { validateInputs } from "../utils/validate"; -import { unstable_cache, revalidateTag } from "next/cache"; +import { EventType, Prisma } from "@prisma/client"; +import { revalidateTag, unstable_cache } from "next/cache"; +import "server-only"; +import { z } from "zod"; import { SERVICES_REVALIDATION_INTERVAL } from "../constants"; +import { validateInputs } from "../utils/validate"; export const getEnvironmentCacheTag = (environmentId: string) => `environments-${environmentId}`; export const getEnvironmentsCacheTag = (productId: string) => `products-${productId}-environments`; @@ -125,86 +134,84 @@ export const updateEnvironment = async ( } }; -export const getEnvironmentByUser = async (user: any): Promise => { - const firstMembership = await prisma.membership.findFirst({ - where: { - userId: user.id, - }, - select: { - teamId: true, - }, - }); - - if (!firstMembership) { - // create a new team and return environment - const membership = await prisma.membership.create({ - data: { - accepted: true, - role: "owner", - user: { connect: { id: user.id } }, - team: { - create: { - name: `${user.name}'s Team`, - products: { - create: { - name: "My Product", - environments: { - create: [ - { - type: EnvironmentType.production, - ...populateEnvironment, - }, - { - type: EnvironmentType.development, - ...populateEnvironment, - }, - ], - }, - }, - }, - }, - }, - }, - include: { - team: { - include: { - products: { - include: { - environments: true, +export const getFirstEnvironmentByUserId = async (userId: string): Promise => { + validateInputs([userId, ZId]); + let environmentPrisma; + try { + environmentPrisma = await prisma.environment.findFirst({ + where: { + type: "production", + product: { + team: { + memberships: { + some: { + userId, }, }, }, }, }, }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } - const environment = membership.team.products[0].environments[0]; + throw error; + } + try { + const environment = ZEnvironment.parse(environmentPrisma); return environment; + } catch (error) { + if (error instanceof z.ZodError) { + console.error(JSON.stringify(error.errors, null, 2)); + } + throw new ValidationError("Data validation of environment failed"); } - - const firstProduct = await prisma.product.findFirst({ - where: { - teamId: firstMembership.teamId, - }, - select: { - id: true, - }, - }); - if (firstProduct === null) { - return null; - } - const firstEnvironment = await prisma.environment.findFirst({ - where: { - productId: firstProduct.id, - type: "production", - }, - select: { - id: true, - }, - }); - if (firstEnvironment === null) { - return null; - } - return firstEnvironment; +}; + +export const createEnvironment = async ( + productId: string, + environmentInput: Partial +): Promise => { + validateInputs([productId, ZId], [environmentInput, ZEnvironmentCreateInput]); + + return await prisma.environment.create({ + data: { + type: environmentInput.type || "development", + product: { connect: { id: productId } }, + widgetSetupCompleted: environmentInput.widgetSetupCompleted || false, + eventClasses: { + create: populateEnvironment.eventClasses, + }, + attributeClasses: { + create: populateEnvironment.attributeClasses, + }, + }, + }); +}; + +export const populateEnvironment = { + eventClasses: [ + { + name: "New Session", + description: "Gets fired when a new session is created", + type: EventType.automatic, + }, + { + name: "Exit Intent (Desktop)", + description: "A user on Desktop leaves the website with the cursor.", + type: EventType.automatic, + }, + { + name: "50% Scroll", + description: "A user scrolled 50% of the current page", + type: EventType.automatic, + }, + ], + attributeClasses: [ + { name: "userId", description: "The internal ID of the person", type: EventType.automatic }, + { name: "email", description: "The email of the person", type: EventType.automatic }, + ], }; diff --git a/packages/lib/services/membership.ts b/packages/lib/services/membership.ts index c1e2ab07ed..16f0123aaa 100644 --- a/packages/lib/services/membership.ts +++ b/packages/lib/services/membership.ts @@ -62,6 +62,26 @@ export const getMembershipsByUserId = cache(async (userId: string): Promise +): Promise => { + try { + const membership = await prisma.membership.create({ + data: { + userId, + teamId, + accepted: data.accepted, + role: data.role as TMembership["role"], + }, + }); + + return membership; + } catch (error) { + throw error; + } +}; export const updateMembership = async ( userId: string, teamId: string, diff --git a/packages/lib/services/product.ts b/packages/lib/services/product.ts index 8072f3a88e..54dca17d8f 100644 --- a/packages/lib/services/product.ts +++ b/packages/lib/services/product.ts @@ -9,11 +9,9 @@ import { Prisma } from "@prisma/client"; import { revalidateTag, unstable_cache } from "next/cache"; import { cache } from "react"; import { z } from "zod"; -import { validateInputs } from "../utils/validate"; -import { EnvironmentType } from "@prisma/client"; -import { EventType } from "@prisma/client"; -import { getEnvironmentCacheTag, getEnvironmentsCacheTag } from "./environment"; import { SERVICES_REVALIDATION_INTERVAL } from "../constants"; +import { validateInputs } from "../utils/validate"; +import { createEnvironment, getEnvironmentCacheTag, getEnvironmentsCacheTag } from "./environment"; export const getProductsCacheTag = (teamId: string): string => `teams-${teamId}-products`; const getProductCacheTag = (environmentId: string): string => `environments-${environmentId}-product`; @@ -35,34 +33,6 @@ const selectProduct = { environments: true, }; -const populateEnvironment = { - eventClasses: { - create: [ - { - name: "New Session", - description: "Gets fired when a new session is created", - type: EventType.automatic, - }, - { - name: "Exit Intent (Desktop)", - description: "A user on Desktop leaves the website with the cursor.", - type: EventType.automatic, - }, - { - name: "50% Scroll", - description: "A user scrolled 50% of the current page", - type: EventType.automatic, - }, - ], - }, - attributeClasses: { - create: [ - { name: "userId", description: "The internal ID of the person", type: EventType.automatic }, - { name: "email", description: "The email of the person", type: EventType.automatic }, - ], - }, -}; - export const getProducts = async (teamId: string): Promise => unstable_cache( async () => { @@ -135,6 +105,7 @@ export const updateProduct = async ( inputProduct: Partial ): Promise => { validateInputs([productId, ZId], [inputProduct, ZProductUpdateInput.partial()]); + const { environments, ...data } = inputProduct; let updatedProduct; try { updatedProduct = await prisma.product.update({ @@ -142,7 +113,10 @@ export const updateProduct = async ( id: productId, }, data: { - ...inputProduct, + ...data, + environments: { + connect: environments?.map((environment) => ({ id: environment.id })) ?? [], + }, }, select: selectProduct, }); @@ -210,43 +184,35 @@ export const deleteProduct = cache(async (productId: string): Promise return product; }); -export const createProduct = async (environmentId: string, productName: string): Promise => { - const environment = await prisma.environment.findUnique({ - where: { id: environmentId }, - select: { - product: { - select: { - teamId: true, - }, - }, - }, - }); - - if (!environment) { - throw new Error("Invalid environment"); +export const createProduct = async ( + teamId: string, + productInput: Partial +): Promise => { + if (!productInput.name) { + throw new ValidationError("Product Name is required"); } + const { environments, ...data } = productInput; - const newProduct = await prisma.product.create({ + let product = await prisma.product.create({ data: { - name: productName, - team: { - connect: { id: environment.product.teamId }, - }, - environments: { - create: [ - { - type: EnvironmentType.production, - ...populateEnvironment, - }, - { - type: EnvironmentType.development, - ...populateEnvironment, - }, - ], - }, + ...data, + name: productInput.name, + teamId, }, select: selectProduct, }); - return newProduct; + const devEnvironment = await createEnvironment(product.id, { + type: "development", + }); + + const prodEnvironment = await createEnvironment(product.id, { + type: "production", + }); + + product = await updateProduct(product.id, { + environments: [devEnvironment, prodEnvironment], + }); + + return product; }; diff --git a/packages/lib/services/profile.ts b/packages/lib/services/profile.ts index b176dff86f..efa9faa552 100644 --- a/packages/lib/services/profile.ts +++ b/packages/lib/services/profile.ts @@ -149,6 +149,19 @@ const deleteUser = async (userId: string): Promise => { return profile; }; +export const createProfile = async (data: TProfileUpdateInput): Promise => { + validateInputs([data, ZProfileUpdateInput]); + const profile = await prisma.user.create({ + data: data, + select: responseSelection, + }); + + revalidateTag(getProfileByEmailCacheTag(profile.email)); + revalidateTag(getProfileCacheTag(profile.id)); + + return profile; +}; + // function to delete a user's profile including teams export const deleteProfile = async (userId: string): Promise => { validateInputs([userId, ZId]); diff --git a/packages/lib/services/team.ts b/packages/lib/services/team.ts index a9417cd90a..63883dca4b 100644 --- a/packages/lib/services/team.ts +++ b/packages/lib/services/team.ts @@ -107,7 +107,20 @@ export const getTeamByEnvironmentId = async (environmentId: string): Promise => { +export const createTeam = async (teamInput: TTeamUpdateInput): Promise => { + try { + const team = await prisma.team.create({ + data: teamInput, + select, + }); + + return team; + } catch (error) { + throw error; + } +}; + +export const updateTeam = async (teamId: string, data: Partial): Promise => { try { const updatedTeam = await prisma.team.update({ where: { diff --git a/packages/lib/survey/auth.ts b/packages/lib/survey/auth.ts new file mode 100644 index 0000000000..263231af5d --- /dev/null +++ b/packages/lib/survey/auth.ts @@ -0,0 +1,24 @@ +import { ZId } from "@formbricks/types/v1/environment"; +import { validateInputs } from "../utils/validate"; +import { hasUserEnvironmentAccess } from "../environment/auth"; +import { getSurvey, getSurveyCacheTag } from "./service"; +import { unstable_cache } from "next/cache"; + +export const canUserAccessSurvey = async (userId: string, surveyId: string): Promise => + await unstable_cache( + async () => { + validateInputs([surveyId, ZId], [userId, ZId]); + + if (!userId) return false; + + const survey = await getSurvey(surveyId); + if (!survey) throw new Error("Survey not found"); + + const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, survey.environmentId); + if (!hasAccessToEnvironment) return false; + + return true; + }, + [`users-${userId}-surveys-${surveyId}`], + { revalidate: 30 * 60, tags: [getSurveyCacheTag(surveyId)] } + )(); // 30 minutes diff --git a/packages/lib/services/survey.ts b/packages/lib/survey/service.ts similarity index 96% rename from packages/lib/services/survey.ts rename to packages/lib/survey/service.ts index b2cd638c03..b6d2ff5ed0 100644 --- a/packages/lib/services/survey.ts +++ b/packages/lib/survey/service.ts @@ -15,22 +15,15 @@ import { revalidateTag, unstable_cache } from "next/cache"; import { z } from "zod"; import { captureTelemetry } from "../telemetry"; import { validateInputs } from "../utils/validate"; -import { getDisplaysCacheTag } from "./displays"; +import { getDisplaysCacheTag } from "../services/displays"; import { getResponsesCacheTag } from "../response/service"; // surveys cache key and tags -const getSurveysCacheKey = (environmentId: string): string => `environments-${environmentId}-surveys`; const getSurveysCacheTag = (environmentId: string): string => `environments-${environmentId}-surveys`; // survey cache key and tags -export const getSurveyCacheKey = (surveyId: string): string => `surveys-${surveyId}`; export const getSurveyCacheTag = (surveyId: string): string => `surveys-${surveyId}`; -// survey with analytics cache key -const getSurveysWithAnalyticsCacheKey = (environmentId: string): string => - `environments-${environmentId}-surveysWithAnalytics`; -const getSurveyWithAnalyticsCacheKey = (surveyId: string): string => `surveyWithAnalytics-${surveyId}`; - export const selectSurvey = { id: true, createdAt: true, @@ -145,7 +138,7 @@ export const getSurveyWithAnalytics = async (surveyId: string): Promise => { throw new ValidationError("Data validation of survey failed"); } }, - [getSurveyCacheKey(surveyId)], + [`surveys-${surveyId}`], { tags: [getSurveyCacheTag(surveyId)], revalidate: 60 * 30, @@ -329,7 +322,7 @@ export const getSurveys = async (environmentId: string): Promise => { throw new ValidationError("Data validation of survey failed"); } }, - [getSurveysCacheKey(environmentId)], + [`environments-${environmentId}-surveys`], { tags: [getSurveysCacheTag(environmentId)], revalidate: 60 * 30, @@ -393,7 +386,7 @@ export const getSurveysWithAnalytics = async (environmentId: string): Promise; + export type TActionClassInput = z.infer; diff --git a/packages/types/v1/attributeClasses.ts b/packages/types/v1/attributeClasses.ts index 2b67849d65..6b492ae669 100644 --- a/packages/types/v1/attributeClasses.ts +++ b/packages/types/v1/attributeClasses.ts @@ -22,12 +22,20 @@ export const ZAttributeClassInput = z.object({ environmentId: z.string(), }); +export const ZAttributeClassAutomaticInput = z.object({ + name: z.string(), + description: z.string(), + type: z.enum(["automatic"]), +}); + export const ZAttributeClassUpdateInput = z.object({ name: z.string(), description: z.string().optional(), archived: z.boolean().optional(), }); +export type TAttributeClassAutomaticInput = z.infer; + export type TAttributeClassUpdateInput = z.infer; export type TAttributeClassInput = z.infer; diff --git a/packages/types/v1/environment.ts b/packages/types/v1/environment.ts index 3910cfb437..90a31bdb1e 100644 --- a/packages/types/v1/environment.ts +++ b/packages/types/v1/environment.ts @@ -25,4 +25,11 @@ export const ZEnvironmentUpdateInput = z.object({ export const ZId = z.string().cuid2(); +export const ZEnvironmentCreateInput = z.object({ + type: z.enum(["development", "production"]).optional(), + widgetSetupCompleted: z.boolean().optional(), +}); + +export type TEnvironmentCreateInput = z.infer; + export type TEnvironmentUpdateInput = z.infer; diff --git a/packages/types/v1/product.ts b/packages/types/v1/product.ts index c686b612f7..4f95cec1de 100644 --- a/packages/types/v1/product.ts +++ b/packages/types/v1/product.ts @@ -11,7 +11,7 @@ export const ZProduct = z.object({ highlightBorderColor: z .string() .regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/) - .nullish(), + .nullable(), recontactDays: z.number().int(), formbricksSignature: z.boolean(), placement: z.enum(["bottomLeft", "bottomRight", "topLeft", "topRight", "center"]), @@ -26,7 +26,6 @@ export const ZProductUpdateInput = ZProduct.omit({ id: true, createdAt: true, updatedAt: true, - environments: true, }); export type TProductUpdateInput = z.infer; diff --git a/packages/types/v1/profile.ts b/packages/types/v1/profile.ts index 62a1e3be00..6999cd3185 100644 --- a/packages/types/v1/profile.ts +++ b/packages/types/v1/profile.ts @@ -21,11 +21,11 @@ export const ZProfile = z.object({ export type TProfile = z.infer; export const ZProfileUpdateInput = z.object({ - name: z.string().nullable(), - email: z.string(), - onboardingCompleted: z.boolean(), - role: ZRole.nullable(), - objective: ZObjective.nullable(), + name: z.string().optional(), + email: z.string().optional(), + onboardingCompleted: z.boolean().optional(), + role: ZRole.optional(), + objective: ZObjective.optional(), }); export type TProfileUpdateInput = z.infer; diff --git a/packages/types/v1/teams.ts b/packages/types/v1/teams.ts index 3eef5b8e07..617058a15a 100644 --- a/packages/types/v1/teams.ts +++ b/packages/types/v1/teams.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { Prisma } from "@prisma/client"; export const ZTeam = z.object({ id: z.string().cuid2(), @@ -10,6 +9,12 @@ export const ZTeam = z.object({ stripeCustomerId: z.string().nullable(), }); -export type TTeamUpdateInput = Prisma.TeamUpdateInput; +export const ZTeamUpdateInput = z.object({ + name: z.string(), + plan: z.enum(["free", "pro"]).optional(), + stripeCustomerId: z.string().nullish(), +}); + +export type TTeamUpdateInput = z.infer; export type TTeam = z.infer;