From 54accbbeffeaa5083530806d478fc996fc4e23aa Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Tue, 9 Jul 2024 13:38:20 +0530 Subject: [PATCH] feat: Improve auth & permission system (#2845) --- .../settings/components/ProductSettings.tsx | 30 +- .../(people)/people/[personId]/actions.ts | 28 +- .../components/DeletePersonButton.tsx | 16 +- .../environments/[environmentId]/actions.ts | 65 +-- .../product/general/actions.ts | 137 ++---- .../general/components/DeleteProduct.tsx | 1 - .../components/DeleteProductRender.tsx | 7 +- .../components/EditProductNameForm.tsx | 22 +- .../components/EditWaitingTimeForm.tsx | 13 +- .../[environmentId]/product/general/page.tsx | 8 +- .../(organization)/members/actions.ts | 32 +- .../components/EditOrganizationName.tsx | 16 +- .../components/EditOrganizationNameForm.tsx | 15 +- apps/web/package.json | 7 +- packages/lib/actionClient/helper.ts | 17 + packages/lib/actionClient/index.ts | 24 + packages/lib/actionClient/permissions.ts | 110 +++++ packages/lib/actionClient/utils.ts | 64 +++ packages/lib/membership/hooks/actions.ts | 15 +- packages/lib/organization/utils.ts | 59 +++ packages/types/actionClient.ts | 16 + packages/ui/SingleResponseCard/actions.ts | 25 +- packages/ui/SingleResponseCard/index.tsx | 2 +- packages/ui/SurveysList/actions.ts | 428 +++++++++--------- .../components/SurveyDropdownMenu.tsx | 7 +- pnpm-lock.yaml | 147 ++++++ 26 files changed, 888 insertions(+), 423 deletions(-) create mode 100644 packages/lib/actionClient/helper.ts create mode 100644 packages/lib/actionClient/index.ts create mode 100644 packages/lib/actionClient/permissions.ts create mode 100644 packages/lib/actionClient/utils.ts create mode 100644 packages/lib/organization/utils.ts create mode 100644 packages/types/actionClient.ts diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx index 9ddfe506c8..deb9a87bca 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx @@ -7,6 +7,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; +import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { PREVIEW_SURVEY } from "@formbricks/lib/styling/constants"; import { TProductConfigChannel, @@ -45,18 +46,27 @@ export const ProductSettings = ({ const addProduct = async (data: TProductUpdateInput) => { try { - const product = await createProductAction(organizationId, { - ...data, - config: { channel, industry }, + const createProductResponse = await createProductAction({ + organizationId, + data: { + ...data, + config: { channel, industry }, + }, }); - // get production environment - const productionEnvironment = product.environments.find( - (environment) => environment.type === "production" - ); - if (channel !== "link") { - router.push(`/environments/${productionEnvironment?.id}/connect`); + + if (createProductResponse?.data) { + // get production environment + const productionEnvironment = createProductResponse.data.environments.find( + (environment) => environment.type === "production" + ); + if (channel !== "link") { + router.push(`/environments/${productionEnvironment?.id}/connect`); + } else { + router.push(`/environments/${productionEnvironment?.id}/surveys`); + } } else { - router.push(`/environments/${productionEnvironment?.id}/surveys`); + const errorMessage = getFormattedErrorMessage(createProductResponse); + toast.error(errorMessage); } } catch (error) { toast.error("Product creation failed"); diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/actions.ts index 8bc4fb0f80..091ddfdfe4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/actions.ts @@ -1,17 +1,23 @@ "use server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@formbricks/lib/authOptions"; -import { canUserAccessPerson } from "@formbricks/lib/person/auth"; +import { z } from "zod"; +import { authenticatedActionClient } from "@formbricks/lib/actionClient"; +import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; +import { getOrganizationIdFromPersonId } from "@formbricks/lib/organization/utils"; import { deletePerson } from "@formbricks/lib/person/service"; -import { AuthorizationError } from "@formbricks/types/errors"; -export const deletePersonAction = async (personId: string) => { - const session = await getServerSession(authOptions); - if (!session) throw new AuthorizationError("Not authorized"); +const ZPersonDeleteAction = z.object({ + personId: z.string(), +}); - const isAuthorized = await canUserAccessPerson(session.user.id, personId); - if (!isAuthorized) throw new AuthorizationError("Not authorized"); +export const deletePersonAction = authenticatedActionClient + .schema(ZPersonDeleteAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorization({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromPersonId(parsedInput.personId), + rules: ["person", "delete"], + }); - await deletePerson(personId); -}; + return await deletePerson(parsedInput.personId); + }); diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/DeletePersonButton.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/DeletePersonButton.tsx index fa8b7d9bff..0899fdd803 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/DeletePersonButton.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/DeletePersonButton.tsx @@ -5,6 +5,7 @@ import { TrashIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import toast from "react-hot-toast"; +import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { DeleteDialog } from "@formbricks/ui/DeleteDialog"; interface DeletePersonButtonProps { @@ -22,14 +23,21 @@ export const DeletePersonButton = ({ environmentId, personId, isViewer }: Delete const handleDeletePerson = async () => { try { setIsDeletingPerson(true); - await deletePersonAction(personId); - router.refresh(); - router.push(`/environments/${environmentId}/people`); - toast.success("Person deleted successfully."); + const deletePersonResponse = await deletePersonAction({ personId }); + + if (deletePersonResponse?.data) { + router.refresh(); + router.push(`/environments/${environmentId}/people`); + toast.success("Person deleted successfully."); + } else { + const errorMessage = getFormattedErrorMessage(deletePersonResponse); + toast.error(errorMessage); + } } catch (error) { toast.error(error.message); } finally { setIsDeletingPerson(false); + setDeleteDialogOpen(false); } }; diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts index 41197e7a3e..418d143175 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts @@ -2,16 +2,19 @@ import { Organization } from "@prisma/client"; import { getServerSession } from "next-auth"; +import { z } from "zod"; import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service"; +import { authenticatedActionClient } from "@formbricks/lib/actionClient"; +import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; import { authOptions } from "@formbricks/lib/authOptions"; import { SHORT_URL_BASE, WEBAPP_URL } from "@formbricks/lib/constants"; -import { createMembership, getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { createMembership } from "@formbricks/lib/membership/service"; import { createOrganization } from "@formbricks/lib/organization/service"; import { createProduct } from "@formbricks/lib/product/service"; import { createShortUrl } from "@formbricks/lib/shortUrl/service"; import { updateUser } from "@formbricks/lib/user/service"; import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors"; -import { TProduct, TProductUpdateInput } from "@formbricks/types/product"; +import { ZProductUpdateInput } from "@formbricks/types/product"; import { TUserNotificationSettings } from "@formbricks/types/user"; export const createShortUrlAction = async (url: string) => { @@ -71,33 +74,39 @@ export const createOrganizationAction = async (organizationName: string): Promis return newOrganization; }; -export const createProductAction = async ( - organizationId: string, - productInput: TProductUpdateInput -): Promise => { - const session = await getServerSession(authOptions); - if (!session) throw new AuthorizationError("Not authenticated"); +const ZCreateProductAction = z.object({ + organizationId: z.string(), + data: ZProductUpdateInput, +}); - const membership = await getMembershipByUserIdOrganizationId(session.user.id, organizationId); - if (!membership || membership.role === "viewer") { - throw new AuthorizationError("Product creation not allowed"); - } +export const createProductAction = authenticatedActionClient + .schema(ZCreateProductAction) + .action(async ({ parsedInput, ctx }) => { + const { user } = ctx; - const product = await createProduct(organizationId, productInput); - const updatedNotificationSettings = { - ...session.user.notificationSettings, - alert: { - ...session.user.notificationSettings?.alert, - }, - weeklySummary: { - ...session.user.notificationSettings?.weeklySummary, - [product.id]: true, - }, - }; + await checkAuthorization({ + schema: ZProductUpdateInput, + data: parsedInput.data, + userId: user.id, + organizationId: parsedInput.organizationId, + rules: ["product", "create"], + }); - await updateUser(session.user.id, { - notificationSettings: updatedNotificationSettings, + const product = await createProduct(parsedInput.organizationId, parsedInput.data); + const updatedNotificationSettings = { + ...user.notificationSettings, + alert: { + ...user.notificationSettings?.alert, + }, + weeklySummary: { + ...user.notificationSettings?.weeklySummary, + [product.id]: true, + }, + }; + + await updateUser(user.id, { + notificationSettings: updatedNotificationSettings, + }); + + return product; }); - - return product; -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/actions.ts b/apps/web/app/(app)/environments/[environmentId]/product/general/actions.ts index 4e3199e8f2..2a6c4665b7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/general/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/product/general/actions.ts @@ -1,104 +1,53 @@ "use server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@formbricks/lib/authOptions"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; -import { getEnvironment } from "@formbricks/lib/environment/service"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; +import { z } from "zod"; +import { authenticatedActionClient } from "@formbricks/lib/actionClient"; +import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; +import { getOrganizationIdFromProductId } from "@formbricks/lib/organization/utils"; import { deleteProduct, getProducts, updateProduct } from "@formbricks/lib/product/service"; -import { TEnvironment } from "@formbricks/types/environment"; -import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; -import { TProduct, TProductUpdateInput } from "@formbricks/types/product"; +import { ZProductUpdateInput } from "@formbricks/types/product"; -export const updateProductAction = async ( - environmentId: string, - productId: string, - data: Partial -): Promise => { - const session = await getServerSession(authOptions); +const ZUpdateProductAction = z.object({ + productId: z.string(), + data: ZProductUpdateInput, +}); - if (!session?.user) { - throw new AuthenticationError("Not authenticated"); - } +export const updateProductAction = authenticatedActionClient + .schema(ZUpdateProductAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorization({ + schema: ZProductUpdateInput, + data: parsedInput.data, + userId: ctx.user.id, + organizationId: await getOrganizationIdFromProductId(parsedInput.productId), + rules: ["product", "update"], + }); - // get the environment from service and check if the user is allowed to update the product - let environment: TEnvironment | null = null; + return await updateProduct(parsedInput.productId, parsedInput.data); + }); - try { - environment = await getEnvironment(environmentId); +const ZProductDeleteAction = z.object({ + productId: z.string(), +}); - if (!environment) { - throw new ResourceNotFoundError("Environment", "Environment not found"); +export const deleteProductAction = authenticatedActionClient + .schema(ZProductDeleteAction) + .action(async ({ ctx, parsedInput }) => { + // get organizationId from productId + const organizationId = await getOrganizationIdFromProductId(parsedInput.productId); + + await checkAuthorization({ + userId: ctx.user.id, + organizationId: organizationId, + rules: ["product", "delete"], + }); + + const availableProducts = (await getProducts(organizationId)) ?? null; + + if (!!availableProducts && availableProducts?.length <= 1) { + throw new Error("You can't delete the last product in the environment."); } - } catch (err) { - throw err; - } - if (!hasUserEnvironmentAccess(session.user.id, environment.id)) { - throw new AuthorizationError("Not authorized"); - } - - const organization = await getOrganizationByEnvironmentId(environmentId); - const membership = organization - ? await getMembershipByUserIdOrganizationId(session.user.id, organization.id) - : null; - - if (!membership) { - throw new AuthorizationError("Not authorized"); - } - - if (membership.role === "viewer") { - throw new AuthorizationError("Not authorized"); - } - - if (membership.role === "developer") { - if (!!data.name || !!data.organizationId || !!data.environments) { - throw new AuthorizationError("Not authorized"); - } - } - - const updatedProduct = await updateProduct(productId, data); - return updatedProduct; -}; - -export const deleteProductAction = async (environmentId: string, productId: string) => { - const session = await getServerSession(authOptions); - - if (!session?.user) { - throw new AuthenticationError("Not authenticated"); - } - const userId = session.user.id; - // get the environment from service and check if the user is allowed to update the product - let environment: TEnvironment | null = null; - - try { - environment = await getEnvironment(environmentId); - - if (!environment) { - throw new ResourceNotFoundError("Environment", "Environment not found"); - } - } catch (err) { - throw err; - } - - if (!hasUserEnvironmentAccess(session.user.id, environment.id)) { - throw new AuthorizationError("Not authorized"); - } - - const organization = await getOrganizationByEnvironmentId(environmentId); - const membership = organization ? await getMembershipByUserIdOrganizationId(userId, organization.id) : null; - - if (membership?.role !== "admin" && membership?.role !== "owner") { - throw new AuthorizationError("You are not allowed to delete products."); - } - - const availableProducts = organization ? await getProducts(organization.id) : null; - - if (!!availableProducts && availableProducts?.length <= 1) { - throw new Error("You can't delete the last product in the environment."); - } - - const deletedProduct = await deleteProduct(productId); - return deletedProduct; -}; + // delete product + return await deleteProduct(parsedInput.productId); + }); diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProduct.tsx b/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProduct.tsx index 96da25fb0a..c4165bf981 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProduct.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProduct.tsx @@ -36,7 +36,6 @@ export const DeleteProduct = async ({ environmentId, product }: DeleteProductPro isDeleteDisabled={isDeleteDisabled} isUserAdminOrOwner={isUserAdminOrOwner} product={product} - environmentId={environmentId} /> ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProductRender.tsx b/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProductRender.tsx index d3b8c543cf..856d67fd61 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProductRender.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProductRender.tsx @@ -10,14 +10,12 @@ import { Button } from "@formbricks/ui/Button"; import { DeleteDialog } from "@formbricks/ui/DeleteDialog"; type DeleteProductRenderProps = { - environmentId: string; isDeleteDisabled: boolean; isUserAdminOrOwner: boolean; product: TProduct; }; export const DeleteProductRender = ({ - environmentId, isDeleteDisabled, isUserAdminOrOwner, product, @@ -25,12 +23,11 @@ export const DeleteProductRender = ({ const router = useRouter(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const handleDeleteProduct = async () => { try { setIsDeleting(true); - const deletedProduct = await deleteProductAction(environmentId, product.id); - if (!!deletedProduct?.id) { + const deletedProductActionResult = await deleteProductAction({ productId: product.id }); + if (deletedProductActionResult?.data) { toast.success("Product deleted successfully."); router.push("/"); } diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/components/EditProductNameForm.tsx b/apps/web/app/(app)/environments/[environmentId]/product/general/components/EditProductNameForm.tsx index 5aeee5f80b..92cf4b36f7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/general/components/EditProductNameForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/general/components/EditProductNameForm.tsx @@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; +import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { TProduct, ZProduct } from "@formbricks/types/product"; import { Button } from "@formbricks/ui/Button"; import { FormControl, FormError, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form"; @@ -12,7 +13,6 @@ import { updateProductAction } from "../actions"; type EditProductNameProps = { product: TProduct; - environmentId: string; isProductNameEditDisabled: boolean; }; @@ -22,7 +22,6 @@ type TEditProductName = z.infer; export const EditProductNameForm: React.FC = ({ product, - environmentId, isProductNameEditDisabled, }) => { const form = useForm({ @@ -46,16 +45,19 @@ export const EditProductNameForm: React.FC = ({ return; } - const updatedProduct = await updateProductAction(environmentId, product.id, { name }); + const updatedProductResponse = await updateProductAction({ + productId: product.id, + data: { + name, + }, + }); - if (isProductNameEditDisabled) { - toast.error("Only Owners, Admins and Editors can perform this action."); - return; - } - - if (!!updatedProduct?.id) { + if (updatedProductResponse?.data) { toast.success("Product name updated successfully."); - form.resetField("name", { defaultValue: updatedProduct.name }); + form.resetField("name", { defaultValue: updatedProductResponse.data.name }); + } else { + const errorMessage = getFormattedErrorMessage(updatedProductResponse); + toast.error(errorMessage); } } catch (err) { console.error(err); diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/components/EditWaitingTimeForm.tsx b/apps/web/app/(app)/environments/[environmentId]/product/general/components/EditWaitingTimeForm.tsx index 3d1bd6743b..ffae1b6f0f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/general/components/EditWaitingTimeForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/general/components/EditWaitingTimeForm.tsx @@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; +import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { TProduct, ZProduct } from "@formbricks/types/product"; import { Button } from "@formbricks/ui/Button"; import { FormControl, FormError, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form"; @@ -11,7 +12,6 @@ import { Input } from "@formbricks/ui/Input"; import { updateProductAction } from "../actions"; type EditWaitingTimeProps = { - environmentId: string; product: TProduct; }; @@ -19,7 +19,7 @@ const ZProductRecontactDaysInput = ZProduct.pick({ recontactDays: true }); type EditWaitingTimeFormValues = z.infer; -export const EditWaitingTimeForm: React.FC = ({ product, environmentId }) => { +export const EditWaitingTimeForm: React.FC = ({ product }) => { const form = useForm({ defaultValues: { recontactDays: product.recontactDays, @@ -32,10 +32,13 @@ export const EditWaitingTimeForm: React.FC = ({ product, e const updateWaitingTime: SubmitHandler = async (data) => { try { - const updatedProduct = await updateProductAction(environmentId, product.id, data); - if (!!updatedProduct?.id) { + const updatedProductResponse = await updateProductAction({ productId: product.id, data }); + if (updatedProductResponse?.data) { toast.success("Waiting period updated successfully."); - form.resetField("recontactDays", { defaultValue: updatedProduct.recontactDays }); + form.resetField("recontactDays", { defaultValue: updatedProductResponse.data.recontactDays }); + } else { + const errorMessage = getFormattedErrorMessage(updatedProductResponse); + toast.error(errorMessage); } } catch (err) { toast.error(`Error: ${err.message}`); diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/page.tsx b/apps/web/app/(app)/environments/[environmentId]/product/general/page.tsx index dcb5269b3f..e2a222dbec 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/general/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/general/page.tsx @@ -57,17 +57,13 @@ const Page = async ({ params }: { params: { environmentId: string } }) => { - + {currentProductChannel !== "link" && ( - + )} { - const session = await getServerSession(authOptions); - if (!session) { - throw new AuthenticationError("Not authenticated"); - } +const ZUpdateOrganizationNameAction = z.object({ + organizationId: z.string(), + data: ZOrganizationUpdateInput.pick({ name: true }), +}); - const isUserAuthorized = await hasOrganizationAuthority(session.user.id, organizationId); - if (!isUserAuthorized) { - throw new AuthenticationError("Not authorized"); - } - - return await updateOrganization(organizationId, { name: organizationName }); -}; +export const updateOrganizationNameAction = authenticatedActionClient + .schema(ZUpdateOrganizationNameAction) + .action(async ({ parsedInput, ctx }) => { + await checkAuthorization({ + schema: ZOrganizationUpdateInput.pick({ name: true }), + data: parsedInput.data, + userId: ctx.user.id, + organizationId: parsedInput.organizationId, + rules: ["organization", "update"], + }); + return await updateOrganization(parsedInput.organizationId, parsedInput.data); + }); export const deleteInviteAction = async (inviteId: string, organizationId: string) => { const session = await getServerSession(authOptions); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditOrganizationName.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditOrganizationName.tsx index d0e1398b00..c533fc0558 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditOrganizationName.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditOrganizationName.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { SubmitHandler, useForm, useWatch } from "react-hook-form"; import toast from "react-hot-toast"; +import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { TMembershipRole } from "@formbricks/types/memberships"; import { TOrganization } from "@formbricks/types/organizations"; @@ -50,11 +51,18 @@ export const EditOrganizationName = ({ organization, membershipRole }: EditOrgan try { data.name = data.name.trim(); setIsUpdatingOrganization(true); - await updateOrganizationNameAction(organization.id, data.name); - - setIsUpdatingOrganization(false); - toast.success("Organization name updated successfully."); + const updatedOrganizationResponse = await updateOrganizationNameAction({ + organizationId: organization.id, + data: { name: data.name }, + }); + if (updatedOrganizationResponse?.data) { + setIsUpdatingOrganization(false); + toast.success("Organization name updated successfully."); + } else { + const errorMessage = getFormattedErrorMessage(updatedOrganizationResponse); + toast.error(errorMessage); + } router.refresh(); } catch (err) { setIsUpdatingOrganization(false); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditOrganizationNameForm.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditOrganizationNameForm.tsx index d77e3e074e..da32dd9c17 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditOrganizationNameForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/members/components/EditOrganizationNameForm.tsx @@ -5,6 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; +import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { TMembershipRole } from "@formbricks/types/memberships"; import { TOrganization, ZOrganization } from "@formbricks/types/organizations"; @@ -37,10 +38,18 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO const handleUpdateOrganizationName: SubmitHandler = async (data) => { try { const name = data.name.trim(); - const updatedOrg = await updateOrganizationNameAction(organization.id, name); + const updatedOrganizationResponse = await updateOrganizationNameAction({ + organizationId: organization.id, + data: { name }, + }); - toast.success("Organization name updated successfully."); - form.reset({ name: updatedOrg.name }); + if (updatedOrganizationResponse?.data) { + toast.success("Organization name updated successfully."); + form.reset({ name: updatedOrganizationResponse.data.name }); + } else { + const errorMessage = getFormattedErrorMessage(updatedOrganizationResponse); + toast.error(errorMessage); + } } catch (err) { toast.error(`Error: ${err.message}`); } diff --git a/apps/web/package.json b/apps/web/package.json index 5e185e4c45..a21f55337d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@formbricks/api": "workspace:*", + "@formbricks/config-tailwind": "workspace:*", "@formbricks/database": "workspace:*", "@formbricks/ee": "workspace:*", "@formbricks/email": "workspace:*", @@ -23,7 +24,6 @@ "@formbricks/js-core": "workspace:*", "@formbricks/lib": "workspace:*", "@formbricks/surveys": "workspace:*", - "@formbricks/config-tailwind": "workspace:*", "@formbricks/types": "workspace:*", "@formbricks/ui": "workspace:*", "@hookform/resolvers": "^3.6.0", @@ -46,6 +46,7 @@ "lucide-react": "^0.397.0", "mime": "^4.0.3", "next": "15.0.0-rc.0", + "next-safe-action": "^7.1.3", "optional": "^0.1.4", "otplib": "^12.0.1", "papaparse": "^5.4.1", @@ -64,12 +65,12 @@ }, "devDependencies": { "@formbricks/config-typescript": "workspace:*", + "@formbricks/eslint-config": "workspace:*", "@neshca/cache-handler": "^1.3.2", "@types/bcryptjs": "^2.4.6", "@types/lodash": "^4.17.5", "@types/markdown-it": "^14.1.1", "@types/papaparse": "^5.3.14", - "@types/qrcode": "^1.5.5", - "@formbricks/eslint-config": "workspace:*" + "@types/qrcode": "^1.5.5" } } diff --git a/packages/lib/actionClient/helper.ts b/packages/lib/actionClient/helper.ts new file mode 100644 index 0000000000..0cb7032070 --- /dev/null +++ b/packages/lib/actionClient/helper.ts @@ -0,0 +1,17 @@ +export const getFormattedErrorMessage = (result) => { + let message = ""; + + if (result.serverError) { + message = result.serverError; + } else { + const errors = result.validationErrors; + message = Object.keys(errors || {}) + .map((key) => { + if (key === "_errors") return errors[key].join(", "); + return `${key ? `${key}` : ""}${errors?.[key]?._errors.join(", ")}`; + }) + .join("\n"); + } + + return message; +}; diff --git a/packages/lib/actionClient/index.ts b/packages/lib/actionClient/index.ts new file mode 100644 index 0000000000..a649fa8f3a --- /dev/null +++ b/packages/lib/actionClient/index.ts @@ -0,0 +1,24 @@ +import { getServerSession } from "next-auth"; +import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action"; +import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { authOptions } from "../authOptions"; + +export const actionClient = createSafeActionClient({ + handleReturnedServerError(e) { + if (e instanceof ResourceNotFoundError) { + return e.message; + } else if (e instanceof AuthorizationError) { + return e.message; + } + + return DEFAULT_SERVER_ERROR_MESSAGE; + }, +}); + +export const authenticatedActionClient = actionClient.use(async ({ next }) => { + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new AuthenticationError("Not authenticated"); + } + return next({ ctx: { user: session.user } }); +}); diff --git a/packages/lib/actionClient/permissions.ts b/packages/lib/actionClient/permissions.ts new file mode 100644 index 0000000000..fee0f6757f --- /dev/null +++ b/packages/lib/actionClient/permissions.ts @@ -0,0 +1,110 @@ +import { ZProductUpdateInput } from "@formbricks/types/product"; + +export const Permissions = { + owner: { + product: { + create: true, + read: true, + update: true, + delete: true, + }, + organization: { + update: true, + }, + person: { + delete: true, + }, + response: { + delete: true, + }, + survey: { + create: true, + }, + }, + + admin: { + product: { + create: true, + read: true, + update: true, + delete: true, + }, + organization: { + update: true, + }, + person: { + delete: true, + }, + response: { + delete: true, + }, + survey: { + create: true, + }, + }, + + editor: { + product: { + create: true, + read: true, + update: true, + delete: true, + }, + organization: { + update: false, + }, + person: { + delete: true, + }, + response: { + delete: true, + }, + survey: { + create: true, + }, + }, + + developer: { + product: { + create: true, + read: true, + update: ZProductUpdateInput.omit({ + name: true, + }), + delete: true, + }, + organization: { + update: false, + }, + person: { + delete: true, + }, + response: { + delete: true, + }, + survey: { + create: true, + }, + }, + + viewer: { + product: { + create: false, + read: true, + update: false, + delete: false, + }, + organization: { + update: false, + }, + person: { + delete: false, + }, + response: { + delete: false, + }, + survey: { + create: false, + }, + }, +}; diff --git a/packages/lib/actionClient/utils.ts b/packages/lib/actionClient/utils.ts new file mode 100644 index 0000000000..f35b93750d --- /dev/null +++ b/packages/lib/actionClient/utils.ts @@ -0,0 +1,64 @@ +import { returnValidationErrors } from "next-safe-action"; +import { ZodIssue, z } from "zod"; +import { TOperation, TResource } from "@formbricks/types/actionClient"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { TMembershipRole } from "@formbricks/types/memberships"; +import { getMembershipRole } from "../membership/hooks/actions"; +import { Permissions } from "./permissions"; + +export const getOperationPermissions = (role: TMembershipRole, entity: TResource, operation: TOperation) => { + const permission = Permissions[role][entity][operation]; + + if (typeof permission === "boolean" && !permission) { + throw new AuthorizationError("Not authorized"); + } + + return permission; +}; + +export const getRoleBasedSchema = ( + schema: z.ZodObject, + role: TMembershipRole, + entity: TResource, + operation: TOperation +): z.ZodObject => { + const data = getOperationPermissions(role, entity, operation); + + return typeof data === "boolean" && data === true ? schema.strict() : data; +}; + +export const formatErrors = (errors: ZodIssue[]) => { + return { + ...errors.reduce((acc, error) => { + acc[error.path.join(".")] = { + _errors: [error.message], + }; + return acc; + }, {}), + }; +}; + +export const checkAuthorization = async ({ + schema, + data, + userId, + organizationId, + rules, +}: { + schema?: z.ZodObject; + data?: z.ZodObject["_output"]; + userId: string; + organizationId: string; + rules: [TResource, TOperation]; +}) => { + const role = await getMembershipRole(userId, organizationId); + if (schema) { + const resultSchema = getRoleBasedSchema(schema, role, ...rules); + const parsedResult = resultSchema.safeParse(data); + if (!parsedResult.success) { + return returnValidationErrors(resultSchema, formatErrors(parsedResult.error.issues)); + } + } else { + getOperationPermissions(role, ...rules); + } +}; diff --git a/packages/lib/membership/hooks/actions.ts b/packages/lib/membership/hooks/actions.ts index 720020d7de..3baf8760e1 100644 --- a/packages/lib/membership/hooks/actions.ts +++ b/packages/lib/membership/hooks/actions.ts @@ -2,7 +2,7 @@ import "server-only"; import { getServerSession } from "next-auth"; -import { AuthenticationError } from "@formbricks/types/errors"; +import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors"; import { TUser } from "@formbricks/types/user"; import { authOptions } from "../../authOptions"; import { getOrganizationByEnvironmentId } from "../../organization/service"; @@ -21,11 +21,16 @@ export const getMembershipByUserIdOrganizationIdAction = async (environmentId: s throw new Error("Organization not found"); } - const currentUserMembership = await getMembershipByUserIdOrganizationId(user.id, organization.id); + const currentUserMembership = await getMembershipRole(user.id, organization.id); - if (!currentUserMembership) { - throw new Error("Membership not found"); + return currentUserMembership; +}; + +export const getMembershipRole = async (userId: string, organizationId: string) => { + const membership = await getMembershipByUserIdOrganizationId(userId, organizationId); + if (!membership) { + throw new AuthorizationError("Not authorized"); } - return currentUserMembership?.role; + return membership.role; }; diff --git a/packages/lib/organization/utils.ts b/packages/lib/organization/utils.ts new file mode 100644 index 0000000000..5bdb212901 --- /dev/null +++ b/packages/lib/organization/utils.ts @@ -0,0 +1,59 @@ +import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { getEnvironment } from "../environment/service"; +import { getPerson } from "../person/service"; +import { getProduct } from "../product/service"; +import { getResponse } from "../response/service"; +import { getSurvey } from "../survey/service"; + +/** + * GET organization ID from RESOURCE ID + */ + +export const getOrganizationIdFromProductId = async (productId: string) => { + const product = await getProduct(productId); + if (!product) { + throw new ResourceNotFoundError("product", productId); + } + + return product.organizationId; +}; + +export const getOrganizationIdFromEnvironmentId = async (environmentId: string) => { + const environment = await getEnvironment(environmentId); + if (!environment) { + throw new ResourceNotFoundError("environment", environmentId); + } + + const organizationId = await getOrganizationIdFromProductId(environment.productId); + return organizationId; +}; + +export const getOrganizationIdFromSurveyId = async (surveyId: string) => { + const survey = await getSurvey(surveyId); + if (!survey) { + throw new ResourceNotFoundError("survey", surveyId); + } + + const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId); + return organizationId; +}; + +export const getOrganizationIdFromResponseId = async (responseId: string) => { + const response = await getResponse(responseId); + if (!response) { + throw new ResourceNotFoundError("response", responseId); + } + + const organizationId = await getOrganizationIdFromSurveyId(response.surveyId); + return organizationId; +}; + +export const getOrganizationIdFromPersonId = async (personId: string) => { + const person = await getPerson(personId); + if (!person) { + throw new ResourceNotFoundError("person", personId); + } + + const organizationId = await getOrganizationIdFromEnvironmentId(person.environmentId); + return organizationId; +}; diff --git a/packages/types/actionClient.ts b/packages/types/actionClient.ts new file mode 100644 index 0000000000..9b4b01cc2e --- /dev/null +++ b/packages/types/actionClient.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const ZResource = z.enum([ + "product", + "organization", + "environment", + "membership", + "invite", + "response", + "survey", + "person", +]); +export type TResource = z.infer; + +export const ZOperation = z.enum(["create", "read", "update", "delete"]); +export type TOperation = z.infer; diff --git a/packages/ui/SingleResponseCard/actions.ts b/packages/ui/SingleResponseCard/actions.ts index 3d94d91e45..4318eebccc 100644 --- a/packages/ui/SingleResponseCard/actions.ts +++ b/packages/ui/SingleResponseCard/actions.ts @@ -1,8 +1,12 @@ "use server"; import { getServerSession } from "next-auth"; +import { z } from "zod"; +import { authenticatedActionClient } from "@formbricks/lib/actionClient"; +import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; import { authOptions } from "@formbricks/lib/authOptions"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { getOrganizationIdFromResponseId } from "@formbricks/lib/organization/utils"; import { canUserAccessResponse } from "@formbricks/lib/response/auth"; import { deleteResponse, getResponse } from "@formbricks/lib/response/service"; import { canUserModifyResponseNote, canUserResolveResponseNote } from "@formbricks/lib/responseNote/auth"; @@ -58,14 +62,21 @@ export const deleteTagOnResponseAction = async (responseId: string, tagId: strin return await deleteTagOnResponse(responseId, tagId); }; -export const deleteResponseAction = async (responseId: string) => { - const session = await getServerSession(authOptions); - if (!session) throw new AuthorizationError("Not authorized"); - const isAuthorized = await canUserAccessResponse(session.user!.id, responseId); - if (!isAuthorized) throw new AuthorizationError("Not authorized"); +const ZDeleteResponseAction = z.object({ + responseId: z.string(), +}); - return await deleteResponse(responseId); -}; +export const deleteResponseAction = authenticatedActionClient + .schema(ZDeleteResponseAction) + .action(async ({ parsedInput, ctx }) => { + await checkAuthorization({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId), + rules: ["response", "delete"], + }); + + return await deleteResponse(parsedInput.responseId); + }); export const updateResponseNoteAction = async (responseNoteId: string, text: string) => { const session = await getServerSession(authOptions); diff --git a/packages/ui/SingleResponseCard/index.tsx b/packages/ui/SingleResponseCard/index.tsx index eabe8548c7..8c6477cfd5 100644 --- a/packages/ui/SingleResponseCard/index.tsx +++ b/packages/ui/SingleResponseCard/index.tsx @@ -90,7 +90,7 @@ export const SingleResponseCard = ({ if (isViewer) { throw new Error("You are not authorized to perform this action."); } - await deleteResponseAction(response.id); + await deleteResponseAction({ responseId: response.id }); deleteResponse?.(response.id); router.refresh(); diff --git a/packages/ui/SurveysList/actions.ts b/packages/ui/SurveysList/actions.ts index c710d54e38..09ffa921d0 100644 --- a/packages/ui/SurveysList/actions.ts +++ b/packages/ui/SurveysList/actions.ts @@ -2,9 +2,13 @@ import { Prisma as prismaClient } from "@prisma/client"; import { getServerSession } from "next-auth"; +import { z } from "zod"; import { prisma } from "@formbricks/database"; +import { authenticatedActionClient } from "@formbricks/lib/actionClient"; +import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; import { authOptions } from "@formbricks/lib/authOptions"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils"; import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone"; import { segmentCache } from "@formbricks/lib/segment/cache"; import { createSegment } from "@formbricks/lib/segment/service"; @@ -36,243 +40,241 @@ export const duplicateSurveyAction = async (environmentId: string, surveyId: str return duplicatedSurvey; }; -export const copyToOtherEnvironmentAction = async ( - environmentId: string, - surveyId: string, - targetEnvironmentId: string -) => { - const session = await getServerSession(authOptions); - if (!session) throw new AuthorizationError("Not authorized"); +const ZCopyToOtherEnvironmentAction = z.object({ + environmentId: z.string(), + surveyId: z.string(), + targetEnvironmentId: z.string(), +}); - const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess( - session.user.id, - environmentId - ); - if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized"); +export const copyToOtherEnvironmentAction = authenticatedActionClient + .schema(ZCopyToOtherEnvironmentAction) + .action(async ({ ctx, parsedInput }) => { + // check if user has access to source and target environments + await checkAuthorization({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), + rules: ["survey", "create"], + }); + await checkAuthorization({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.targetEnvironmentId), + rules: ["survey", "create"], + }); - 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, - environmentId, - }, - include: { - triggers: { - include: { - actionClass: true, - }, + const existingSurvey = await prisma.survey.findFirst({ + where: { + id: parsedInput.surveyId, + environmentId: parsedInput.environmentId, }, - attributeFilters: { - include: { - attributeClass: true, + include: { + triggers: { + include: { + actionClass: true, + }, }, - }, - languages: { - select: { - default: true, - enabled: true, - language: { - select: { - id: true, + attributeFilters: { + include: { + attributeClass: true, + }, + }, + languages: { + select: { + default: true, + enabled: true, + language: { + select: { + id: true, + }, }, }, }, - }, - segment: true, - }, - }); - - if (!existingSurvey) { - throw new ResourceNotFoundError("Survey", surveyId); - } - - let targetEnvironmentTriggers: string[] = []; - // map the local triggers to the target environment - for (const trigger of existingSurvey.triggers) { - const targetEnvironmentTrigger = await prisma.actionClass.findFirst({ - where: { - ...(trigger.actionClass.type === "code" - ? { key: trigger.actionClass.key } - : { name: trigger.actionClass.name }), - environment: { - id: targetEnvironmentId, - }, + segment: true, }, }); - if (!targetEnvironmentTrigger) { - // if the trigger does not exist in the target environment, create it - const newTrigger = await prisma.actionClass.create({ - data: { - name: trigger.actionClass.name, - environment: { - connect: { - id: targetEnvironmentId, - }, - }, - description: trigger.actionClass.description, - type: trigger.actionClass.type, + + if (!existingSurvey) { + throw new ResourceNotFoundError("Survey", parsedInput.surveyId); + } + + let targetEnvironmentTriggers: string[] = []; + // map the local triggers to the target environment + for (const trigger of existingSurvey.triggers) { + const targetEnvironmentTrigger = await prisma.actionClass.findFirst({ + where: { ...(trigger.actionClass.type === "code" ? { key: trigger.actionClass.key } - : { - noCodeConfig: trigger.actionClass.noCodeConfig - ? structuredClone(trigger.actionClass.noCodeConfig) - : undefined, - }), + : { name: trigger.actionClass.name }), + environment: { + id: parsedInput.targetEnvironmentId, + }, }, }); - targetEnvironmentTriggers.push(newTrigger.id); - } else { - targetEnvironmentTriggers.push(targetEnvironmentTrigger.id); + if (!targetEnvironmentTrigger) { + // if the trigger does not exist in the target environment, create it + const newTrigger = await prisma.actionClass.create({ + data: { + name: trigger.actionClass.name, + environment: { + connect: { + id: parsedInput.targetEnvironmentId, + }, + }, + description: trigger.actionClass.description, + type: trigger.actionClass.type, + ...(trigger.actionClass.type === "code" + ? { key: trigger.actionClass.key } + : { + noCodeConfig: trigger.actionClass.noCodeConfig + ? structuredClone(trigger.actionClass.noCodeConfig) + : undefined, + }), + }, + }); + targetEnvironmentTriggers.push(newTrigger.id); + } else { + targetEnvironmentTriggers.push(targetEnvironmentTrigger.id); + } } - } - let targetEnvironmentAttributeFilters: string[] = []; - // map the local attributeFilters to the target env - for (const attributeFilter of existingSurvey.attributeFilters) { - // check if attributeClass exists in target env. - // if not, create it - const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({ - where: { - name: attributeFilter.attributeClass.name, - environment: { - id: targetEnvironmentId, + let targetEnvironmentAttributeFilters: string[] = []; + // map the local attributeFilters to the target env + for (const attributeFilter of existingSurvey.attributeFilters) { + // check if attributeClass exists in target env. + // if not, create it + const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({ + where: { + name: attributeFilter.attributeClass.name, + environment: { + id: parsedInput.targetEnvironmentId, + }, }, + }); + if (!targetEnvironmentAttributeClass) { + const newAttributeClass = await prisma.attributeClass.create({ + data: { + name: attributeFilter.attributeClass.name, + description: attributeFilter.attributeClass.description, + type: attributeFilter.attributeClass.type, + environment: { + connect: { + id: parsedInput.targetEnvironmentId, + }, + }, + }, + }); + targetEnvironmentAttributeFilters.push(newAttributeClass.id); + } else { + targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id); + } + } + + const defaultLanguageId = existingSurvey.languages.find((l) => l.default)?.language.id; + + // create new survey with the data of the existing survey + const newSurvey = await prisma.survey.create({ + data: { + ...existingSurvey, + id: undefined, // id is auto-generated + environmentId: undefined, // environmentId is set below + createdBy: undefined, + segmentId: undefined, + name: `${existingSurvey.name} (copy)`, + status: "draft", + questions: structuredClone(existingSurvey.questions), + thankYouCard: structuredClone(existingSurvey.thankYouCard), + languages: { + create: existingSurvey.languages?.map((surveyLanguage) => ({ + languageId: surveyLanguage.language.id, + default: surveyLanguage.language.id === defaultLanguageId, + })), + }, + triggers: { + create: targetEnvironmentTriggers.map((actionClassId) => ({ + actionClassId: actionClassId, + })), + }, + attributeFilters: { + create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({ + attributeClassId: targetEnvironmentAttributeFilters[idx], + condition: attributeFilter.condition, + value: attributeFilter.value, + })), + }, + environment: { + connect: { + id: parsedInput.targetEnvironmentId, + }, + }, + creator: { + connect: { + id: ctx.user.id, + }, + }, + surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull, + singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull, + productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull, + verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull, + styling: existingSurvey.styling ?? prismaClient.JsonNull, + segment: undefined, }, }); - if (!targetEnvironmentAttributeClass) { - const newAttributeClass = await prisma.attributeClass.create({ - data: { - name: attributeFilter.attributeClass.name, - description: attributeFilter.attributeClass.description, - type: attributeFilter.attributeClass.type, - environment: { - connect: { - id: targetEnvironmentId, + + // if the existing survey has an inline segment, we copy the filters and create a new inline segment and connect it to the new survey + if (existingSurvey.segment) { + if (existingSurvey.segment.isPrivate) { + const newInlineSegment = await createSegment({ + environmentId: parsedInput.environmentId, + title: `${newSurvey.id}`, + isPrivate: true, + surveyId: newSurvey.id, + filters: existingSurvey.segment.filters, + }); + + await prisma.survey.update({ + where: { + id: newSurvey.id, + }, + data: { + segment: { + connect: { + id: newInlineSegment.id, + }, }, }, - }, - }); - targetEnvironmentAttributeFilters.push(newAttributeClass.id); - } else { - targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id); + }); + + segmentCache.revalidate({ + id: newInlineSegment.id, + environmentId: newSurvey.environmentId, + }); + } else { + await prisma.survey.update({ + where: { + id: newSurvey.id, + }, + data: { + segment: { + connect: { + id: existingSurvey.segment.id, + }, + }, + }, + }); + + segmentCache.revalidate({ + id: existingSurvey.segment.id, + environmentId: newSurvey.environmentId, + }); + } } - } - const defaultLanguageId = existingSurvey.languages.find((l) => l.default)?.language.id; - - // create new survey with the data of the existing survey - const newSurvey = await prisma.survey.create({ - data: { - ...existingSurvey, - id: undefined, // id is auto-generated - environmentId: undefined, // environmentId is set below - createdBy: undefined, - segmentId: undefined, - name: `${existingSurvey.name} (copy)`, - status: "draft", - questions: structuredClone(existingSurvey.questions), - thankYouCard: structuredClone(existingSurvey.thankYouCard), - languages: { - create: existingSurvey.languages?.map((surveyLanguage) => ({ - languageId: surveyLanguage.language.id, - default: surveyLanguage.language.id === defaultLanguageId, - })), - }, - triggers: { - create: targetEnvironmentTriggers.map((actionClassId) => ({ - actionClassId: actionClassId, - })), - }, - attributeFilters: { - create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({ - attributeClassId: targetEnvironmentAttributeFilters[idx], - condition: attributeFilter.condition, - value: attributeFilter.value, - })), - }, - environment: { - connect: { - id: targetEnvironmentId, - }, - }, - creator: { - connect: { - id: session.user.id, - }, - }, - surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull, - singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull, - productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull, - verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull, - styling: existingSurvey.styling ?? prismaClient.JsonNull, - segment: undefined, - }, + surveyCache.revalidate({ + id: newSurvey.id, + environmentId: parsedInput.targetEnvironmentId, + }); + return newSurvey; }); - // if the existing survey has an inline segment, we copy the filters and create a new inline segment and connect it to the new survey - if (existingSurvey.segment) { - if (existingSurvey.segment.isPrivate) { - const newInlineSegment = await createSegment({ - environmentId, - title: `${newSurvey.id}`, - isPrivate: true, - surveyId: newSurvey.id, - filters: existingSurvey.segment.filters, - }); - - await prisma.survey.update({ - where: { - id: newSurvey.id, - }, - data: { - segment: { - connect: { - id: newInlineSegment.id, - }, - }, - }, - }); - - segmentCache.revalidate({ - id: newInlineSegment.id, - environmentId: newSurvey.environmentId, - }); - } else { - await prisma.survey.update({ - where: { - id: newSurvey.id, - }, - data: { - segment: { - connect: { - id: existingSurvey.segment.id, - }, - }, - }, - }); - - segmentCache.revalidate({ - id: existingSurvey.segment.id, - environmentId: newSurvey.environmentId, - }); - } - } - - surveyCache.revalidate({ - id: newSurvey.id, - environmentId: targetEnvironmentId, - }); - return newSurvey; -}; - export const deleteSurveyAction = async (surveyId: string) => { const session = await getServerSession(authOptions); if (!session) throw new AuthorizationError("Not authorized"); diff --git a/packages/ui/SurveysList/components/SurveyDropdownMenu.tsx b/packages/ui/SurveysList/components/SurveyDropdownMenu.tsx index 7ebf3c5cac..f6eb77c59f 100644 --- a/packages/ui/SurveysList/components/SurveyDropdownMenu.tsx +++ b/packages/ui/SurveysList/components/SurveyDropdownMenu.tsx @@ -85,7 +85,12 @@ export const SurveyDropDownMenu = ({ const copyToOtherEnvironment = async (surveyId: string) => { setLoading(true); try { - await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id); + // await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id); + await copyToOtherEnvironmentAction({ + environmentId, + surveyId, + targetEnvironmentId: otherEnvironment.id, + }); if (otherEnvironment.type === "production") { toast.success("Survey copied to production env."); } else if (otherEnvironment.type === "development") { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0eb0b5481a..aa77d4a3e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -414,6 +414,9 @@ importers: next: specifier: 15.0.0-rc.0 version: 15.0.0-rc.0(@opentelemetry/api@1.9.0)(@playwright/test@1.45.0)(react-dom@19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524))(react@19.0.0-rc-935180c7e0-20240524) + next-safe-action: + specifier: ^7.1.3 + version: 7.1.3(@types/json-schema@7.0.15)(next@15.0.0-rc.0(@opentelemetry/api@1.9.0)(@playwright/test@1.45.0)(react-dom@19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524))(react@19.0.0-rc-935180c7e0-20240524))(react-dom@19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524))(react@19.0.0-rc-935180c7e0-20240524)(zod@3.23.8) optional: specifier: ^0.1.4 version: 0.1.4 @@ -5313,6 +5316,87 @@ packages: '@types/ws@8.5.10': resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} + '@typeschema/core@0.13.2': + resolution: {integrity: sha512-pAt0MK249/9szYaoPuvzhSfOd3smrLhhwCCpUNB4onX32mRx5F3lzDIveIYGQkLYRq58xOX5sjoW+n72f/MLLw==} + peerDependencies: + '@types/json-schema': ^7.0.15 + peerDependenciesMeta: + '@types/json-schema': + optional: true + + '@typeschema/main@0.13.10': + resolution: {integrity: sha512-ArdFC4GbgdVWWgPKg2tymxx2KHMus3xZ8I2kHwqw/0P4FtWBXCmSNAiBqDtpoXXF8h9cbcm7fVpcs5ftoWT9+A==} + peerDependencies: + '@typeschema/arktype': 0.13.2 + '@typeschema/class-validator': 0.1.2 + '@typeschema/deepkit': 0.13.4 + '@typeschema/effect': 0.13.4 + '@typeschema/fastest-validator': 0.1.0 + '@typeschema/function': 0.13.2 + '@typeschema/io-ts': 0.13.3 + '@typeschema/joi': 0.13.3 + '@typeschema/json': 0.13.3 + '@typeschema/ow': 0.13.3 + '@typeschema/runtypes': 0.13.2 + '@typeschema/superstruct': 0.13.2 + '@typeschema/suretype': 0.1.0 + '@typeschema/typebox': 0.13.4 + '@typeschema/valibot': 0.13.5 + '@typeschema/valita': 0.1.0 + '@typeschema/vine': 0.1.0 + '@typeschema/yup': 0.13.3 + '@typeschema/zod': 0.13.3 + peerDependenciesMeta: + '@typeschema/arktype': + optional: true + '@typeschema/class-validator': + optional: true + '@typeschema/deepkit': + optional: true + '@typeschema/effect': + optional: true + '@typeschema/fastest-validator': + optional: true + '@typeschema/function': + optional: true + '@typeschema/io-ts': + optional: true + '@typeschema/joi': + optional: true + '@typeschema/json': + optional: true + '@typeschema/ow': + optional: true + '@typeschema/runtypes': + optional: true + '@typeschema/superstruct': + optional: true + '@typeschema/suretype': + optional: true + '@typeschema/typebox': + optional: true + '@typeschema/valibot': + optional: true + '@typeschema/valita': + optional: true + '@typeschema/vine': + optional: true + '@typeschema/yup': + optional: true + '@typeschema/zod': + optional: true + + '@typeschema/zod@0.13.3': + resolution: {integrity: sha512-p5Hs22WIKkM/vZTAvw5QOLSA0EJ6QBUsQMGUrXlYnTAE2LSR/F5MLsDUb18O6S5VxGjrzU7x3VIznD5qOafJRw==} + peerDependencies: + zod: ^3.22.4 + zod-to-json-schema: ^3.22.4 + peerDependenciesMeta: + zod: + optional: true + zod-to-json-schema: + optional: true + '@typescript-eslint/eslint-plugin@7.13.1': resolution: {integrity: sha512-kZqi+WZQaZfPKnsflLJQCz6Ze9FFSMfXrrIOcyargekQxG37ES7DJNpJUE9Q/X5n3yTIP/WPutVNzgknQ7biLg==} engines: {node: ^18.18.0 || >=20.0.0} @@ -8933,6 +9017,18 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + next-safe-action@7.1.3: + resolution: {integrity: sha512-WWqgoExw2BjiVzTZPryW+rIMRhcrnEhvnKmofCDTNfDD6pmAYXk3f8tM199iTuKGYEHrJDtffs3fggQZlPuMzQ==} + engines: {node: '>=18.17'} + peerDependencies: + next: '>= 14.0.0' + react: '>= 18.2.0' + react-dom: '>= 18.2.0' + zod: '>= 3.0.0' + peerDependenciesMeta: + zod: + optional: true + next-seo@6.5.0: resolution: {integrity: sha512-MfzUeWTN/x/rsKp/1n0213eojO97lIl0unxqbeCY+6pAucViHDA8GSLRRcXpgjsSmBxfCFdfpu7LXbt4ANQoNQ==} peerDependencies: @@ -17801,6 +17897,26 @@ snapshots: dependencies: '@types/node': 20.14.5 + '@typeschema/core@0.13.2(@types/json-schema@7.0.15)': + optionalDependencies: + '@types/json-schema': 7.0.15 + + '@typeschema/main@0.13.10(@types/json-schema@7.0.15)(@typeschema/zod@0.13.3(@types/json-schema@7.0.15)(zod@3.23.8))': + dependencies: + '@typeschema/core': 0.13.2(@types/json-schema@7.0.15) + optionalDependencies: + '@typeschema/zod': 0.13.3(@types/json-schema@7.0.15)(zod@3.23.8) + transitivePeerDependencies: + - '@types/json-schema' + + '@typeschema/zod@0.13.3(@types/json-schema@7.0.15)(zod@3.23.8)': + dependencies: + '@typeschema/core': 0.13.2(@types/json-schema@7.0.15) + optionalDependencies: + zod: 3.23.8 + transitivePeerDependencies: + - '@types/json-schema' + '@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)': dependencies: '@eslint-community/regexpp': 4.10.1 @@ -22332,6 +22448,37 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + next-safe-action@7.1.3(@types/json-schema@7.0.15)(next@15.0.0-rc.0(@opentelemetry/api@1.9.0)(@playwright/test@1.45.0)(react-dom@19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524))(react@19.0.0-rc-935180c7e0-20240524))(react-dom@19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524))(react@19.0.0-rc-935180c7e0-20240524)(zod@3.23.8): + dependencies: + '@typeschema/main': 0.13.10(@types/json-schema@7.0.15)(@typeschema/zod@0.13.3(@types/json-schema@7.0.15)(zod@3.23.8)) + '@typeschema/zod': 0.13.3(@types/json-schema@7.0.15)(zod@3.23.8) + next: 15.0.0-rc.0(@opentelemetry/api@1.9.0)(@playwright/test@1.45.0)(react-dom@19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524))(react@19.0.0-rc-935180c7e0-20240524) + react: 19.0.0-rc-935180c7e0-20240524 + react-dom: 19.0.0-rc-935180c7e0-20240524(react@19.0.0-rc-935180c7e0-20240524) + optionalDependencies: + zod: 3.23.8 + transitivePeerDependencies: + - '@types/json-schema' + - '@typeschema/arktype' + - '@typeschema/class-validator' + - '@typeschema/deepkit' + - '@typeschema/effect' + - '@typeschema/fastest-validator' + - '@typeschema/function' + - '@typeschema/io-ts' + - '@typeschema/joi' + - '@typeschema/json' + - '@typeschema/ow' + - '@typeschema/runtypes' + - '@typeschema/superstruct' + - '@typeschema/suretype' + - '@typeschema/typebox' + - '@typeschema/valibot' + - '@typeschema/valita' + - '@typeschema/vine' + - '@typeschema/yup' + - zod-to-json-schema + next-seo@6.5.0(next@14.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: next: 14.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)