diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/product/DeleteProduct.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/product/DeleteProduct.tsx
new file mode 100644
index 0000000000..610ab9df07
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/product/DeleteProduct.tsx
@@ -0,0 +1,32 @@
+import { getProducts } from "@formbricks/lib/services/product";
+import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
+import { TProduct } from "@formbricks/types/v1/product";
+import DeleteProductRender from "@/app/(app)/environments/[environmentId]/settings/product/DeleteProductRender";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+
+type DeleteProductProps = {
+ environmentId: string;
+ product: TProduct;
+};
+
+export default async function DeleteProduct({ environmentId, product }: DeleteProductProps) {
+ const session = await getServerSession(authOptions);
+ const team = await getTeamByEnvironmentId(environmentId);
+ const availableProducts = team ? await getProducts(team.id) : null;
+
+ const role = team ? session?.user.teams.find((foundTeam) => foundTeam.id === team.id)?.role : null;
+ const availableProductsLength = availableProducts ? availableProducts.length : 0;
+ const isUserAdminOrOwner = role === "admin" || role === "owner";
+ const isDeleteDisabled = availableProductsLength <= 1 || !isUserAdminOrOwner;
+
+ return (
+
+ );
+}
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/product/DeleteProductRender.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/product/DeleteProductRender.tsx
new file mode 100644
index 0000000000..25ae93cb6b
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/product/DeleteProductRender.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { deleteProductAction } from "@/app/(app)/environments/[environmentId]/settings/product/actions";
+import DeleteDialog from "@/components/shared/DeleteDialog";
+import { truncate } from "@/lib/utils";
+import { TProduct } from "@formbricks/types/v1/product";
+import { Button } from "@formbricks/ui";
+import { useRouter } from "next/navigation";
+import React, { useState } from "react";
+import toast from "react-hot-toast";
+
+type DeleteProductRenderProps = {
+ environmentId: string;
+ isDeleteDisabled: boolean;
+ isUserAdminOrOwner: boolean;
+ product: TProduct;
+ userId: string;
+};
+
+const DeleteProductRender: React.FC
= ({
+ environmentId,
+ isDeleteDisabled,
+ isUserAdminOrOwner,
+ product,
+ userId,
+}) => {
+ const router = useRouter();
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+
+ const handleDeleteProduct = async () => {
+ try {
+ const deletedProduct = await deleteProductAction(environmentId, userId, product.id);
+
+ if (!!deletedProduct?.id) {
+ toast.success("Product deleted successfully.");
+ router.push("/");
+ }
+ } catch (err) {
+ toast.error("Could not delete product.");
+ setIsDeleteDialogOpen(false);
+ }
+ };
+
+ return (
+
+ {!isDeleteDisabled && (
+
+
+ Delete {truncate(product.name, 30)}
+ incl. all surveys, responses, people, actions and attributes.{" "}
+ This action cannot be undone.
+
+
setIsDeleteDialogOpen(true)}>
+ Delete
+
+
+ )}
+
+ {isDeleteDisabled && (
+
+ {!isUserAdminOrOwner
+ ? "Only Admin or Owners can delete products."
+ : "This is your only product, it cannot be deleted. Create a new product first."}
+
+ )}
+
+
+
+ );
+};
+
+export default DeleteProductRender;
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/product/EditProductName.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/product/EditProductName.tsx
new file mode 100644
index 0000000000..420f444286
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/product/EditProductName.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { updateProductAction } from "./actions";
+import { TProduct } from "@formbricks/types/v1/product";
+import { useRouter } from "next/navigation";
+import { SubmitHandler, useForm } from "react-hook-form";
+import toast from "react-hot-toast";
+import { Button, Input, Label } from "@formbricks/ui";
+
+type TEditProductName = {
+ name: string;
+};
+
+type EditProductNameProps = {
+ product: TProduct;
+ environmentId: string;
+};
+
+const EditProductName: React.FC = ({ product, environmentId }) => {
+ const router = useRouter();
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ defaultValues: {
+ name: product.name,
+ },
+ });
+
+ const updateProduct: SubmitHandler = async (data) => {
+ try {
+ await updateProductAction(environmentId, product.id, data);
+ toast.success("Product name updated successfully.");
+
+ router.refresh();
+ } catch (err) {
+ toast.error(`Error: ${err.message}`);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default EditProductName;
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/product/EditWaitingTime.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/product/EditWaitingTime.tsx
new file mode 100644
index 0000000000..984f34c4a7
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/product/EditWaitingTime.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { SubmitHandler, useForm } from "react-hook-form";
+import toast from "react-hot-toast";
+import { useRouter } from "next/navigation";
+
+import { Button, Input, Label } from "@formbricks/ui";
+import { TProduct } from "@formbricks/types/v1/product";
+import { updateProductAction } from "./actions";
+
+type EditWaitingTimeFormValues = {
+ recontactDays: number;
+};
+
+type EditWaitingTimeProps = {
+ environmentId: string;
+ product: TProduct;
+};
+
+const EditWaitingTime: React.FC = ({ product, environmentId }) => {
+ const router = useRouter();
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ defaultValues: {
+ recontactDays: product.recontactDays,
+ },
+ });
+
+ const updateWaitingTime: SubmitHandler = async (data) => {
+ try {
+ await updateProductAction(environmentId, product.id, data);
+ toast.success("Waiting period updated successfully.");
+
+ router.refresh();
+ } catch (err) {
+ toast.error(`Error: ${err.message}`);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default EditWaitingTime;
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/product/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/product/actions.ts
new file mode 100644
index 0000000000..d78b19bc46
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/product/actions.ts
@@ -0,0 +1,84 @@
+"use server";
+
+import { deleteProduct, getProducts, updateProduct } from "@formbricks/lib/services/product";
+import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
+import { getServerSession } from "next-auth";
+import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
+import { getEnvironment } from "@formbricks/lib/services/environment";
+import { TEnvironment } from "@formbricks/types/v1/environment";
+import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
+import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
+import { getMembershipByUserId } from "@formbricks/lib/services/membership";
+
+export const updateProductAction = async (
+ environmentId: string,
+ productId: string,
+ data: Partial
+): Promise => {
+ const session = await getServerSession();
+
+ if (!session?.user) {
+ throw new AuthenticationError("Not authenticated");
+ }
+
+ // 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, environment.id)) {
+ throw new AuthenticationError("You don't have access to this environment");
+ }
+
+ const updatedProduct = await updateProduct(productId, data);
+ return updatedProduct;
+};
+
+export const deleteProductAction = async (environmentId: string, userId: string, productId: string) => {
+ const session = await getServerSession();
+
+ if (!session?.user) {
+ throw new AuthenticationError("Not authenticated");
+ }
+
+ // 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, environment.id)) {
+ throw new AuthenticationError("You don't have access to this environment");
+ }
+
+ const team = await getTeamByEnvironmentId(environmentId);
+ const membership = team ? await getMembershipByUserId(userId, team.id) : null;
+
+ if (membership?.role !== "admin" && membership?.role !== "owner") {
+ throw new AuthenticationError("You are not allowed to delete products.");
+ }
+
+ const availableProducts = team ? await getProducts(team.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;
+};
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/product/editProduct.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/product/editProduct.tsx
deleted file mode 100644
index 1c43680bb9..0000000000
--- a/apps/web/app/(app)/environments/[environmentId]/settings/product/editProduct.tsx
+++ /dev/null
@@ -1,206 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { useForm, useWatch } from "react-hook-form";
-import toast from "react-hot-toast";
-import { useRouter } from "next/navigation";
-
-import DeleteDialog from "@/components/shared/DeleteDialog";
-import LoadingSpinner from "@/components/shared/LoadingSpinner";
-
-import { deleteProduct, useProduct } from "@/lib/products/products";
-import { truncate } from "@/lib/utils";
-
-import { useEnvironment } from "@/lib/environments/environments";
-import { useProductMutation } from "@/lib/products/mutateProducts";
-import { Button, ErrorComponent, Input, Label } from "@formbricks/ui";
-import { useProfile } from "@/lib/profile";
-import { useMembers } from "@/lib/members";
-
-export function EditProductName({ environmentId }) {
- const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
- const { isMutatingProduct, triggerProductMutate } = useProductMutation(environmentId);
- const { mutateEnvironment } = useEnvironment(environmentId);
-
- const { register, handleSubmit, control, setValue } = useForm();
-
- const productName = useWatch({
- control,
- name: "name",
- });
- const isProductNameInputEmpty = !productName?.trim();
- const currentProductName = productName?.trim().toLowerCase() ?? "";
- const previousProductName = product?.name?.trim().toLowerCase() ?? "";
-
- useEffect(() => {
- setValue("name", product?.name ?? "");
- }, [product?.name]);
-
- if (isLoadingProduct) {
- return ;
- }
- if (isErrorProduct) {
- return ;
- }
-
- return (
-
- );
-}
-
-export function EditWaitingTime({ environmentId }) {
- const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
- const { isMutatingProduct, triggerProductMutate } = useProductMutation(environmentId);
-
- const { register, handleSubmit } = useForm();
-
- if (isLoadingProduct) {
- return ;
- }
- if (isErrorProduct) {
- return ;
- }
-
- return (
-
- );
-}
-
-export function DeleteProduct({ environmentId }) {
- const router = useRouter();
-
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- const [deletingProduct, setDeletingProduct] = useState(false);
-
- const { profile } = useProfile();
- const { team } = useMembers(environmentId);
- const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
- const { environment } = useEnvironment(environmentId);
-
- const availableProducts = environment?.availableProducts?.length;
- const role = team?.members?.filter((member) => member?.userId === profile?.id)[0]?.role;
- const isUserAdminOrOwner = role === "admin" || role === "owner";
- const isDeleteDisabled = availableProducts <= 1 || !isUserAdminOrOwner;
-
- if (isLoadingProduct) {
- return ;
- }
- if (isErrorProduct) {
- return ;
- }
-
- const handleDeleteProduct = async () => {
- if (environment?.availableProducts?.length <= 1) {
- toast.error("Cannot delete product. Your team needs at least 1.");
- setIsDeleteDialogOpen(false);
- return;
- }
- setDeletingProduct(true);
- const deleteProductRes = await deleteProduct(environmentId);
- setDeletingProduct(false);
-
- if (deleteProductRes?.id?.length > 0) {
- toast.success("Product deleted successfully.");
- router.push("/");
- } else if (deleteProductRes?.message?.length > 0) {
- toast.error(deleteProductRes.message);
- setIsDeleteDialogOpen(false);
- } else {
- toast.error("Error deleting product. Please try again.");
- }
- };
-
- return (
-
- {!isDeleteDisabled && (
-
-
- Delete {truncate(product?.name, 30)}
- incl. all surveys, responses, people, actions and attributes.{" "}
- This action cannot be undone.
-
-
setIsDeleteDialogOpen(true)}>
- Delete
-
-
- )}
- {isDeleteDisabled && (
-
- {!isUserAdminOrOwner
- ? "Only Admin or Owners can delete products."
- : "This is your only product, it cannot be deleted. Create a new product first."}
-
- )}
-
-
- );
-}
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/product/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/product/loading.tsx
new file mode 100644
index 0000000000..d4b116e749
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/product/loading.tsx
@@ -0,0 +1,49 @@
+function LoadingCard({ title, description, skeletonLines }) {
+ return (
+
+
+
{title}
+
{description}
+
+
+
+ {skeletonLines.map((line, index) => (
+
+ ))}
+
+
+
+ );
+}
+
+export default function Loading() {
+ const cards = [
+ {
+ title: "Product Name",
+ description: "Change your products name.",
+ skeletonLines: [{ classes: "h-4 w-28" }, { classes: "h-6 w-64" }, { classes: "h-8 w-24" }],
+ },
+ {
+ title: "Recontact Waiting Time",
+ description: "Control how frequently users can be surveyed across all surveys.",
+ skeletonLines: [{ classes: "h-4 w-28" }, { classes: "h-6 w-64" }, { classes: "h-8 w-24" }],
+ },
+ {
+ title: "Delete Product",
+ description:
+ "Delete product with all surveys, responses, people, actions and attributes. This cannot be undone.",
+ skeletonLines: [{ classes: "h-4 w-96" }, { classes: "h-8 w-24" }],
+ },
+ ];
+
+ return (
+
+
Product Settings
+ {cards.map((card, index) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/product/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/product/page.tsx
index fd42db7896..0af1b64da5 100644
--- a/apps/web/app/(app)/environments/[environmentId]/settings/product/page.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/product/page.tsx
@@ -1,23 +1,38 @@
+import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
+
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
-import { EditProductName, EditWaitingTime, DeleteProduct } from "./editProduct";
-export default function ProfileSettingsPage({ params }) {
+import EditProductName from "./EditProductName";
+import EditWaitingTime from "./EditWaitingTime";
+import DeleteProduct from "./DeleteProduct";
+import { getEnvironment } from "@formbricks/lib/services/environment";
+
+export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
+ const [, product] = await Promise.all([
+ getEnvironment(params.environmentId),
+ getProductByEnvironmentId(params.environmentId),
+ ]);
+
+ if (!product) {
+ throw new Error("Product not found");
+ }
+
return (
-
+
-
+
-
+
);
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx
index ef99ad31cc..5058fbb62a 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/SurveyList.tsx
@@ -19,6 +19,9 @@ export default async function SurveysList({ environmentId }: { environmentId: st
}
const environment = await getEnvironment(environmentId);
+ if (!environment) {
+ throw new Error("Environment not found");
+ }
const surveys: TSurveyWithAnalytics[] = await getSurveysWithAnalytics(environmentId);
const environments: TEnvironment[] = await getEnvironments(product.id);
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/page.tsx
index 395ee7d6fa..9e4c312f3d 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/page.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/page.tsx
@@ -11,6 +11,10 @@ export default async function SurveyTemplatesPage({ params }) {
throw new Error("Product not found");
}
+ if (!environment) {
+ throw new Error("Environment not found");
+ }
+
return (
);
diff --git a/packages/lib/services/environment.ts b/packages/lib/services/environment.ts
index 1f6c9d966f..0cb54da5e0 100644
--- a/packages/lib/services/environment.ts
+++ b/packages/lib/services/environment.ts
@@ -9,7 +9,7 @@ import { ZEnvironment, ZEnvironmentUpdateInput, ZId } from "@formbricks/types/v1
import { cache } from "react";
import { validateInputs } from "../utils/validate";
-export const getEnvironment = cache(async (environmentId: string): Promise => {
+export const getEnvironment = cache(async (environmentId: string): Promise => {
validateInputs([environmentId, ZId]);
let environmentPrisma;
try {
@@ -19,9 +19,7 @@ export const getEnvironment = cache(async (environmentId: string): Promise => {
+ const membership = await prisma.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId,
+ teamId,
+ },
+ },
+ });
+
+ if (!membership) return null;
+
+ return membership;
+ }
+);
diff --git a/packages/lib/services/product.ts b/packages/lib/services/product.ts
index 4cc71b5f85..a3d726c4e6 100644
--- a/packages/lib/services/product.ts
+++ b/packages/lib/services/product.ts
@@ -49,6 +49,7 @@ export const getProductByEnvironmentId = cache(async (environmentId: string): Pr
throw new ValidationError("EnvironmentId is required");
}
let productPrisma;
+
try {
productPrisma = await prisma.product.findFirst({
where: {
@@ -90,7 +91,6 @@ export const updateProduct = async (
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
- throw error;
}
try {
const product = ZProduct.parse(updatedProduct);
@@ -121,3 +121,13 @@ export const getProduct = cache(async (productId: string): Promise => {
+ const product = await prisma.product.delete({
+ where: {
+ id: productId,
+ },
+ });
+
+ return product;
+});
diff --git a/packages/lib/services/profile.ts b/packages/lib/services/profile.ts
index 691a5338a6..f00f7a58b6 100644
--- a/packages/lib/services/profile.ts
+++ b/packages/lib/services/profile.ts
@@ -1,14 +1,12 @@
import { prisma } from "@formbricks/database";
+import { ZId } from "@formbricks/types/v1/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
-import { Prisma } from "@prisma/client";
-import { TProfile, ZProfileUpdateInput } from "@formbricks/types/v1/profile";
-import { deleteTeam } from "./team";
-import { MembershipRole } from "@prisma/client";
+import { TMembership, TMembershipRole, ZMembershipRole } from "@formbricks/types/v1/memberships";
+import { TProfile, TProfileUpdateInput, ZProfileUpdateInput } from "@formbricks/types/v1/profile";
+import { MembershipRole, Prisma } from "@prisma/client";
import { cache } from "react";
import { validateInputs } from "../utils/validate";
-import { ZId } from "@formbricks/types/v1/environment";
-import { TMembership, TMembershipRole, ZMembershipRole } from "@formbricks/types/v1/membership";
-import { TProfileUpdateInput } from "@formbricks/types/v1/profile";
+import { deleteTeam } from "./team";
const responseSelection = {
id: true,
@@ -59,7 +57,7 @@ const updateUserMembership = async (teamId: string, userId: string, role: TMembe
});
};
-const getAdminMemberships = (memberships: TMembership[]) =>
+const getAdminMemberships = (memberships: TMembership[]): TMembership[] =>
memberships.filter((membership) => membership.role === MembershipRole.admin);
// function to update a user's profile
@@ -107,12 +105,7 @@ export const deleteProfile = async (personId: string): Promise => {
select: {
id: true,
name: true,
- memberships: {
- select: {
- userId: true,
- role: true,
- },
- },
+ memberships: true,
},
},
},
diff --git a/packages/types/v1/membership.ts b/packages/types/v1/memberships.ts
similarity index 81%
rename from packages/types/v1/membership.ts
rename to packages/types/v1/memberships.ts
index f735046875..467050fff0 100644
--- a/packages/types/v1/membership.ts
+++ b/packages/types/v1/memberships.ts
@@ -1,11 +1,14 @@
-import { z } from "zod";
+import z from "zod";
export const ZMembershipRole = z.enum(["owner", "admin", "editor", "developer", "viewer"]);
+export type TMembershipRole = z.infer;
+
export const ZMembership = z.object({
- role: ZMembershipRole,
+ teamId: z.string(),
userId: z.string(),
+ accepted: z.boolean(),
+ role: ZMembershipRole,
});
export type TMembership = z.infer;
-export type TMembershipRole = z.infer;