feat: add ability to copy surveys between different environments of different products (#2832)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Smriti Doneria
2024-08-05 20:34:58 +05:30
committed by GitHub
parent f10bd9c0d8
commit c65c1af023
12 changed files with 559 additions and 414 deletions

View File

@@ -44,7 +44,6 @@ const Page = async ({ params }) => {
getServerSession(authOptions),
getSegments(params.environmentId),
]);
if (!session) {
throw new Error("Session not found");
}

View File

@@ -93,7 +93,7 @@ const Page = async ({ params, searchParams }: LinkSurveyPageProps) => {
if (isSingleUseSurvey) {
try {
singleUseResponse = singleUseId
? (await getResponseBySingleUseId(survey.id, singleUseId)) ?? undefined
? ((await getResponseBySingleUseId(survey.id, singleUseId)) ?? undefined)
: undefined;
} catch (error) {
singleUseResponse = undefined;

View File

@@ -3,7 +3,11 @@ import { type TActionClassNoCodeConfig } from "@formbricks/types/action-classes"
import { type TIntegrationConfig } from "@formbricks/types/integration";
import { type TOrganizationBilling } from "@formbricks/types/organizations";
import { type TProductConfig, type TProductStyling } from "@formbricks/types/product";
import { type TResponseData, type TResponseMeta, type TResponsePersonAttributes } from "@formbricks/types/responses";
import {
type TResponseData,
type TResponseMeta,
type TResponsePersonAttributes,
} from "@formbricks/types/responses";
import { type TBaseFilters } from "@formbricks/types/segment";
import {
type TSurveyClosedMessage,

View File

@@ -1,13 +1,15 @@
import "server-only";
import { createId } from "@paralleldrive/cuid2";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/action-classes";
import { ZOptionalNumber } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { TEnvironment, ZId } from "@formbricks/types/environment";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TLegacySurvey } from "@formbricks/types/legacy-surveys";
import { TPerson } from "@formbricks/types/people";
import { TProduct } from "@formbricks/types/product";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import {
TSurvey,
@@ -17,6 +19,7 @@ import {
ZSurveyCreateInput,
} from "@formbricks/types/surveys/types";
import { getActionsByPersonId } from "../action/service";
import { actionClassCache } from "../actionClass/cache";
import { getActionClasses } from "../actionClass/service";
import { attributeCache } from "../attribute/cache";
import { getAttributes } from "../attribute/service";
@@ -24,6 +27,7 @@ import { cache } from "../cache";
import { ITEMS_PER_PAGE } from "../constants";
import { displayCache } from "../display/cache";
import { getDisplaysByPersonId } from "../display/service";
import { getEnvironment } from "../environment/service";
import { reverseTranslateSurvey } from "../i18n/reverseTranslation";
import { subscribeOrganizationMembersToSurveyResponses } from "../organization/service";
import { personCache } from "../person/cache";
@@ -92,6 +96,7 @@ export const selectSurvey = {
alias: true,
createdAt: true,
updatedAt: true,
productId: true,
},
},
},
@@ -122,7 +127,7 @@ export const selectSurvey = {
},
},
},
};
} satisfies Prisma.SurveySelect;
const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: TActionClass[]) => {
if (!triggers) return;
@@ -712,114 +717,219 @@ export const createSurvey = async (
}
};
export const duplicateSurvey = async (environmentId: string, surveyId: string, userId: string) => {
validateInputs([environmentId, ZId], [surveyId, ZId]);
export const copySurveyToOtherEnvironment = async (
environmentId: string,
surveyId: string,
targetEnvironmentId: string,
userId: string
) => {
validateInputs([environmentId, ZId], [surveyId, ZId], [targetEnvironmentId, ZId], [userId, ZId]);
try {
const existingSurvey = await getSurvey(surveyId);
const currentDate = new Date();
if (!existingSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
const isSameEnvironment = environmentId === targetEnvironmentId;
// Fetch required resources
const [existingEnvironment, existingProduct, existingSurvey] = await Promise.all([
getEnvironment(environmentId),
getProductByEnvironmentId(environmentId),
getSurvey(surveyId),
]);
if (!existingEnvironment) throw new ResourceNotFoundError("Environment", environmentId);
if (!existingProduct) throw new ResourceNotFoundError("Product", environmentId);
if (!existingSurvey) throw new ResourceNotFoundError("Survey", surveyId);
let targetEnvironment: TEnvironment | null = null;
let targetProduct: TProduct | null = null;
if (isSameEnvironment) {
targetEnvironment = existingEnvironment;
targetProduct = existingProduct;
} else {
[targetEnvironment, targetProduct] = await Promise.all([
getEnvironment(targetEnvironmentId),
getProductByEnvironmentId(targetEnvironmentId),
]);
if (!targetEnvironment) throw new ResourceNotFoundError("Environment", targetEnvironmentId);
if (!targetProduct) throw new ResourceNotFoundError("Product", targetEnvironmentId);
}
const defaultLanguageId = existingSurvey.languages.find((l) => l.default)?.language.id;
const {
environmentId: _,
createdBy,
id: existingSurveyId,
createdAt,
updatedAt,
...restExistingSurvey
} = existingSurvey;
const hasLanguages = existingSurvey.languages && existingSurvey.languages.length > 0;
// 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
createdAt: currentDate,
updatedAt: currentDate,
createdBy: undefined,
name: `${existingSurvey.name} (copy)`,
status: "draft",
questions: structuredClone(existingSurvey.questions),
endings: structuredClone(existingSurvey.endings),
languages: {
create: existingSurvey.languages?.map((surveyLanguage) => ({
languageId: surveyLanguage.language.id,
default: surveyLanguage.language.id === defaultLanguageId,
})),
},
triggers: {
create: existingSurvey.triggers.map((trigger) => ({
actionClassId: trigger.actionClass.id,
})),
},
environment: {
connect: {
id: environmentId,
},
},
creator: {
connect: {
id: userId,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage
? structuredClone(existingSurvey.surveyClosedMessage)
: Prisma.JsonNull,
singleUse: existingSurvey.singleUse ? structuredClone(existingSurvey.singleUse) : Prisma.JsonNull,
productOverwrites: existingSurvey.productOverwrites
? structuredClone(existingSurvey.productOverwrites)
: Prisma.JsonNull,
styling: existingSurvey.styling ? structuredClone(existingSurvey.styling) : Prisma.JsonNull,
// we'll update the segment later
segment: undefined,
// Prepare survey data
const surveyData: Prisma.SurveyCreateInput = {
...restExistingSurvey,
id: createId(),
name: `${existingSurvey.name} (copy)`,
type: existingSurvey.type,
status: "draft",
welcomeCard: structuredClone(existingSurvey.welcomeCard),
questions: structuredClone(existingSurvey.questions),
endings: structuredClone(existingSurvey.endings),
hiddenFields: structuredClone(existingSurvey.hiddenFields),
languages: hasLanguages
? {
create: existingSurvey.languages.map((surveyLanguage) => ({
language: {
connectOrCreate: {
where: {
productId_code: { code: surveyLanguage.language.code, productId: targetProduct.id },
},
create: {
code: surveyLanguage.language.code,
alias: surveyLanguage.language.alias,
productId: targetProduct.id,
},
},
},
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
})),
}
: undefined,
triggers: {
create: existingSurvey.triggers.map((trigger): Prisma.SurveyTriggerCreateWithoutSurveyInput => {
const baseActionClassData = {
name: trigger.actionClass.name,
environment: { connect: { id: targetEnvironmentId } },
description: trigger.actionClass.description,
type: trigger.actionClass.type,
};
if (isSameEnvironment) {
return {
actionClass: { connect: { id: trigger.actionClass.id } },
};
} else if (trigger.actionClass.type === "code") {
return {
actionClass: {
connectOrCreate: {
where: {
key_environmentId: { key: trigger.actionClass.key!, environmentId: targetEnvironmentId },
},
create: {
...baseActionClassData,
key: trigger.actionClass.key,
},
},
},
};
} else {
return {
actionClass: {
connectOrCreate: {
where: {
name_environmentId: {
name: trigger.actionClass.name,
environmentId: targetEnvironmentId,
},
},
create: {
...baseActionClassData,
noCodeConfig: trigger.actionClass.noCodeConfig
? structuredClone(trigger.actionClass.noCodeConfig)
: undefined,
},
},
},
};
}
}),
},
});
environment: {
connect: {
id: targetEnvironmentId,
},
},
creator: {
connect: {
id: userId,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage
? structuredClone(existingSurvey.surveyClosedMessage)
: Prisma.JsonNull,
singleUse: existingSurvey.singleUse ? structuredClone(existingSurvey.singleUse) : Prisma.JsonNull,
productOverwrites: existingSurvey.productOverwrites
? structuredClone(existingSurvey.productOverwrites)
: Prisma.JsonNull,
styling: existingSurvey.styling ? structuredClone(existingSurvey.styling) : Prisma.JsonNull,
segment: undefined,
};
// 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
// Handle segment
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,
surveyData.segment = {
create: {
title: surveyData.id!,
isPrivate: true,
filters: existingSurvey.segment.filters,
environment: { connect: { id: targetEnvironmentId } },
},
data: {
segment: {
connect: {
id: newInlineSegment.id,
},
},
},
});
segmentCache.revalidate({
id: newInlineSegment.id,
environmentId: newSurvey.environmentId,
});
};
} else if (isSameEnvironment) {
surveyData.segment = { connect: { id: existingSurvey.segment.id } };
} else {
await prisma.survey.update({
const existingSegmentInTargetEnvironment = await prisma.segment.findFirst({
where: {
id: newSurvey.id,
},
data: {
segment: {
connect: {
id: existingSurvey.segment.id,
},
},
title: existingSurvey.segment.title,
isPrivate: false,
environmentId: targetEnvironmentId,
},
});
segmentCache.revalidate({
id: existingSurvey.segment.id,
environmentId: newSurvey.environmentId,
});
surveyData.segment = {
create: {
title: existingSegmentInTargetEnvironment
? `${existingSurvey.segment.title}-${Date.now()}`
: existingSurvey.segment.title,
isPrivate: false,
filters: existingSurvey.segment.filters,
environment: { connect: { id: targetEnvironmentId } },
},
};
}
}
const targetProductLanguageCodes = targetProduct.languages.map((language) => language.code);
const newSurvey = await prisma.survey.create({
data: surveyData,
select: selectSurvey,
});
// Identify newly created action classes
const newActionClasses = newSurvey.triggers.map((trigger) => trigger.actionClass);
// Revalidate cache only for newly created action classes
for (const actionClass of newActionClasses) {
actionClassCache.revalidate({
environmentId: actionClass.environmentId,
name: actionClass.name,
id: actionClass.id,
});
}
let newLanguageCreated = false;
if (existingSurvey.languages && existingSurvey.languages.length > 0) {
const targetLanguageCodes = newSurvey.languages.map((lang) => lang.language.code);
newLanguageCreated = targetLanguageCodes.length > targetProductLanguageCodes.length;
}
// Invalidate caches
if (newLanguageCreated) {
productCache.revalidate({ id: targetProduct.id, environmentId: targetEnvironmentId });
}
surveyCache.revalidate({
id: newSurvey.id,
environmentId: newSurvey.environmentId,
@@ -832,13 +942,19 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
});
});
if (newSurvey.segment) {
segmentCache.revalidate({
id: newSurvey.segment.id,
environmentId: newSurvey.environmentId,
});
}
return newSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,6 +1,7 @@
import { Prisma } from "@prisma/client";
import { TActionClass } from "@formbricks/types/action-classes";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProduct } from "@formbricks/types/product";
import {
@@ -87,6 +88,16 @@ export const mockDisplay = {
status: null,
};
export const mockEnvironment: TEnvironment = {
id: mockId,
createdAt: currentDate,
updatedAt: currentDate,
type: "production",
productId: mockId,
appSetupCompleted: false,
websiteSetupCompleted: false,
};
export const mockUser: TUser = {
id: mockId,
name: "mock User",

View File

@@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it } from "vitest";
import { testInputValidation } from "vitestSetup";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
copySurveyToOtherEnvironment,
createSurvey,
deleteSurvey,
duplicateSurvey,
getSurvey,
getSurveyCount,
getSurveys,
@@ -19,6 +19,7 @@ import {
mockActionClass,
mockAttributeClass,
mockDisplay,
mockEnvironment,
mockId,
mockOrganizationOutput,
mockPrismaPerson,
@@ -254,23 +255,34 @@ describe("Tests for duplicateSurvey", () => {
it("Duplicates a survey successfully", async () => {
prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
prisma.survey.create.mockResolvedValueOnce(mockSurveyOutput);
const createdSurvey = await duplicateSurvey(mockId, mockId, mockId);
// @ts-expect-error
prisma.environment.findUnique.mockResolvedValueOnce(mockEnvironment);
// @ts-expect-error
prisma.product.findFirst.mockResolvedValueOnce(mockProduct);
prisma.actionClass.findFirst.mockResolvedValueOnce(mockActionClass);
prisma.actionClass.create.mockResolvedValueOnce(mockActionClass);
const createdSurvey = await copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId, mockId);
expect(createdSurvey).toEqual(mockSurveyOutput);
});
});
describe("Sad Path", () => {
testInputValidation(duplicateSurvey, "123#", "123#");
testInputValidation(copySurveyToOtherEnvironment, "123#", "123#", "123#", "123#", "123#");
it("Throws ResourceNotFoundError if the survey does not exist", async () => {
prisma.survey.findUnique.mockRejectedValueOnce(new ResourceNotFoundError("Survey", mockId));
await expect(duplicateSurvey(mockId, mockId, mockId)).rejects.toThrow(ResourceNotFoundError);
await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId, mockId)).rejects.toThrow(
ResourceNotFoundError
);
});
it("should throw an error if there is an unknown error", async () => {
const mockErrorMessage = "Unknown error occurred";
prisma.survey.create.mockRejectedValue(new Error(mockErrorMessage));
await expect(duplicateSurvey(mockId, mockId, mockId)).rejects.toThrow(Error);
await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId, mockId)).rejects.toThrow(
Error
);
});
});
});

View File

@@ -1387,3 +1387,14 @@ export const ZSurveyRecallItem = z.object({
});
export type TSurveyRecallItem = z.infer<typeof ZSurveyRecallItem>;
export const ZSurveyCopyFormValidation = z.object({
products: z.array(
z.object({
product: z.string(),
environments: z.array(z.string()),
})
),
});
export type TSurveyCopyFormData = z.infer<typeof ZSurveyCopyFormValidation>;

View File

@@ -1,22 +1,19 @@
"use server";
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";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProducts } from "@formbricks/lib/product/service";
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { deleteSurvey, duplicateSurvey, getSurvey, getSurveys } from "@formbricks/lib/survey/service";
import {
copySurveyToOtherEnvironment,
deleteSurvey,
getSurvey,
getSurveys,
} from "@formbricks/lib/survey/service";
import { generateSurveySingleUseId } from "@formbricks/lib/utils/singleUseSurveys";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { AuthorizationError } from "@formbricks/types/errors";
import { TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
export const getSurveyAction = async (surveyId: string) => {
@@ -29,251 +26,50 @@ export const getSurveyAction = async (surveyId: string) => {
return await getSurvey(surveyId);
};
export const duplicateSurveyAction = async (environmentId: string, surveyId: string) => {
export const copySurveyToOtherEnvironmentAction = async (
environmentId: string,
surveyId: string,
targetEnvironmentId: 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 isSameEnvironment = environmentId === targetEnvironmentId;
const duplicatedSurvey = await duplicateSurvey(environmentId, surveyId, session.user.id);
return duplicatedSurvey;
// Optimize authorization checks
if (isSameEnvironment) {
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
} else {
const [sourceAccess, targetAccess] = await Promise.all([
hasUserEnvironmentAccess(session.user.id, environmentId),
hasUserEnvironmentAccess(session.user.id, targetEnvironmentId),
]);
if (!sourceAccess || !targetAccess) throw new AuthorizationError("Not authorized");
}
const isAuthorizedForSurvey = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorizedForSurvey) throw new AuthorizationError("Not authorized");
return await copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, session.user.id);
};
const ZCopyToOtherEnvironmentAction = z.object({
environmentId: z.string(),
surveyId: z.string(),
targetEnvironmentId: z.string(),
});
export const getProductsByEnvironmentIdAction = async (environmentId: string) => {
const session = await getServerSession(authOptions);
if (!session) 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 isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const existingSurvey = await prisma.survey.findFirst({
where: {
id: parsedInput.surveyId,
environmentId: parsedInput.environmentId,
},
include: {
triggers: {
include: {
actionClass: true,
},
},
attributeFilters: {
include: {
attributeClass: true,
},
},
languages: {
select: {
default: true,
enabled: true,
language: {
select: {
id: true,
},
},
},
},
segment: true,
},
});
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!existingSurvey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
if (!organization) {
throw new Error("No organization found");
}
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: parsedInput.targetEnvironmentId,
},
},
});
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: 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),
endings: structuredClone(existingSurvey.endings),
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,
},
},
isVerifyEmailEnabled: existingSurvey.isVerifyEmailEnabled,
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
styling: existingSurvey.styling ?? prismaClient.JsonNull,
segment: undefined,
},
});
// 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,
},
},
},
});
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: parsedInput.targetEnvironmentId,
});
return newSurvey;
});
const products = await getProducts(organization.id);
return products;
};
export const deleteSurveyAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);

View File

@@ -0,0 +1,153 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useFieldArray, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TProduct } from "@formbricks/types/product";
import { TSurvey, TSurveyCopyFormData, ZSurveyCopyFormValidation } from "@formbricks/types/surveys/types";
import { Button } from "../../Button";
import { Checkbox } from "../../Checkbox";
import { FormControl, FormField, FormItem, FormProvider } from "../../Form";
import { Label } from "../../Label";
import { TooltipRenderer } from "../../Tooltip";
import { copySurveyToOtherEnvironmentAction } from "../actions";
export const CopySurveyForm = ({
defaultProducts,
survey,
onCancel,
setOpen,
}: {
defaultProducts: TProduct[];
survey: TSurvey;
onCancel: () => void;
setOpen: (value: boolean) => void;
}) => {
const form = useForm<TSurveyCopyFormData>({
resolver: zodResolver(ZSurveyCopyFormValidation),
defaultValues: {
products: defaultProducts.map((product) => ({
product: product.id,
environments: [],
})),
},
});
const formFields = useFieldArray({
name: "products",
control: form.control,
});
const onSubmit = async (data: TSurveyCopyFormData) => {
const filteredData = data.products.filter((product) => product.environments.length > 0);
try {
filteredData.map(async (product) => {
product.environments.map(async (environment) => {
await copySurveyToOtherEnvironmentAction(survey.environmentId, survey.id, environment);
});
});
toast.success("Survey copied successfully!");
} catch (error) {
toast.error("Failed to copy survey");
} finally {
setOpen(false);
}
};
return (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="relative flex h-full w-full flex-col gap-8 overflow-y-auto bg-white p-4">
<div className="space-y-8 pb-12">
{formFields.fields.map((field, productIndex) => {
const product = defaultProducts.find((product) => product.id === field.product);
const isDisabled = survey.type !== "link" && product?.config.channel !== survey.type;
return (
<div key={product?.id}>
<div className="flex flex-col gap-4">
<TooltipRenderer
shouldRender={isDisabled}
tooltipContent={
<span>
This product is not compatible with the survey type. Please select a different
product.
</span>
}>
<div className="w-fit">
<p className="text-base font-semibold text-slate-900">
{product?.name}
{isDisabled && <span className="ml-2 mr-11 text-sm text-gray-500">(Disabled)</span>}
</p>
</div>
</TooltipRenderer>
<div className="flex flex-col gap-4">
{product?.environments.map((environment) => {
return (
<FormField
control={form.control}
name={`products.${productIndex}.environments`}
render={({ field }) => {
return (
<FormItem>
<div className="flex items-center">
<FormControl>
<>
<Checkbox
{...field}
type="button"
onClick={(e) => {
if (isDisabled) {
e.preventDefault();
}
}}
onCheckedChange={() => {
if (field.value.includes(environment.id)) {
field.onChange(
field.value.filter((id: string) => id !== environment.id)
);
} else {
field.onChange([...field.value, environment.id]);
}
}}
className="mr-2 h-4 w-4 appearance-none border-gray-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
disabled={isDisabled}
id={environment.id}
/>
<Label htmlFor={environment.id}>
<p className="text-sm font-medium capitalize text-slate-900">
{environment.type}
</p>
</Label>
</>
</FormControl>
</div>
</FormItem>
);
}}
/>
);
})}
</div>
</div>
</div>
);
})}
</div>
<div className="fixed bottom-0 left-0 right-0 z-10 flex w-full justify-end space-x-2 bg-white">
<div className="flex w-full justify-end pb-4 pr-4">
<Button type="button" onClick={onCancel} variant="minimal">
Cancel
</Button>
<Button variant="primary" type="submit">
Copy survey
</Button>
</div>
</div>
</form>
</FormProvider>
);
};

View File

@@ -0,0 +1,37 @@
import { MousePointerClickIcon } from "lucide-react";
import { TSurvey } from "@formbricks/types/surveys/types";
import { Modal } from "../../Modal";
import SurveyCopyOptions from "./SurveyCopyOptions";
interface CopySurveyModalProps {
open: boolean;
setOpen: (value: boolean) => void;
survey: TSurvey;
}
export const CopySurveyModal = ({ open, setOpen, survey }: CopySurveyModalProps) => (
<Modal open={open} setOpen={setOpen} noPadding restrictOverflow>
<div className="flex h-full flex-col rounded-lg">
<div className="fixed left-0 right-0 z-10 h-24 rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<MousePointerClickIcon className="h-6 w-6 text-slate-500" />
<div>
<div className="text-xl font-medium text-slate-700">Copy Survey</div>
<div className="text-sm text-slate-500">Copy this survey to another environment</div>
</div>
</div>
</div>
</div>
<div className="h-full max-h-[500px] overflow-auto pl-4 pt-24">
<SurveyCopyOptions
survey={survey}
environmentId={survey.environmentId}
onCancel={() => setOpen(false)}
setOpen={setOpen}
/>
</div>
</div>
</Modal>
);

View File

@@ -0,0 +1,46 @@
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getProductsByEnvironmentIdAction } from "../actions";
import { CopySurveyForm } from "./CopySurveyForm";
interface SurveyCopyOptionsProps {
survey: TSurvey;
environmentId: string;
onCancel: () => void;
setOpen: (value: boolean) => void;
}
const SurveyCopyOptions = ({ environmentId, survey, onCancel, setOpen }: SurveyCopyOptionsProps) => {
const [products, setProducts] = useState<TProduct[]>([]);
const [productLoading, setProductLoading] = useState(true);
useEffect(() => {
const fetchProducts = async () => {
try {
const products = await getProductsByEnvironmentIdAction(environmentId);
setProducts(products);
} catch (error) {
toast.error("Error fetching products");
} finally {
setProductLoading(false);
}
};
fetchProducts();
}, [environmentId]);
if (productLoading) {
return (
<div className="relative flex h-full min-h-96 w-full items-center justify-center bg-white pb-12">
<Loader2 className="animate-spin" />
</div>
);
}
return <CopySurveyForm defaultProducts={products} survey={survey} onCancel={onCancel} setOpen={setOpen} />;
};
export default SurveyCopyOptions;

View File

@@ -16,13 +16,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "../../DropdownMenu";
import { LoadingSpinner } from "../../LoadingSpinner";
import {
copyToOtherEnvironmentAction,
deleteSurveyAction,
duplicateSurveyAction,
getSurveyAction,
} from "../actions";
import { copySurveyToOtherEnvironmentAction, deleteSurveyAction, getSurveyAction } from "../actions";
import { CopySurveyModal } from "./CopySurveyModal";
interface SurveyDropDownMenuProps {
environmentId: string;
@@ -39,8 +34,6 @@ interface SurveyDropDownMenuProps {
export const SurveyDropDownMenu = ({
environmentId,
survey,
environment,
otherEnvironment,
webAppUrl,
singleUseId,
isSurveyCreationDeletionDisabled,
@@ -50,6 +43,7 @@ export const SurveyDropDownMenu = ({
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
const router = useRouter();
const surveyUrl = useMemo(() => webAppUrl + "/s/" + survey.id, [survey.id, webAppUrl]);
@@ -71,7 +65,11 @@ export const SurveyDropDownMenu = ({
const duplicateSurveyAndRefresh = async (surveyId: string) => {
setLoading(true);
try {
const duplicatedSurvey = await duplicateSurveyAction(environmentId, surveyId);
const duplicatedSurvey = await copySurveyToOtherEnvironmentAction(
environmentId,
surveyId,
environmentId
);
router.refresh();
const transformedDuplicatedSurvey = await getSurveyAction(duplicatedSurvey.id);
if (transformedDuplicatedSurvey) duplicateSurvey(transformedDuplicatedSurvey);
@@ -82,33 +80,6 @@ export const SurveyDropDownMenu = ({
setLoading(false);
};
const copyToOtherEnvironment = async (surveyId: string) => {
setLoading(true);
try {
// 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") {
toast.success("Survey copied to development env.");
}
router.replace(`/environments/${otherEnvironment.id}`);
} catch (error) {
toast.error(`Failed to copy to ${otherEnvironment.type}`);
}
setLoading(false);
};
if (loading) {
return (
<div className="opacity-0.2 absolute left-0 top-0 h-full w-full bg-slate-100">
<LoadingSpinner />
</div>
);
}
return (
<div
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
@@ -150,35 +121,20 @@ export const SurveyDropDownMenu = ({
)}
{!isSurveyCreationDeletionDisabled && (
<>
{environment.type === "development" ? (
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
copyToOtherEnvironment(survey.id);
}}>
<ArrowUpFromLineIcon className="mr-2 h-4 w-4" />
Copy to Prod
</button>
</DropdownMenuItem>
) : environment.type === "production" ? (
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
copyToOtherEnvironment(survey.id);
}}>
<ArrowUpFromLineIcon className="mr-2 h-4 w-4" />
Copy to Dev
</button>
</DropdownMenuItem>
) : null}
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
disabled={loading}
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setIsCopyFormOpen(true);
}}>
<ArrowUpFromLineIcon className="mr-2 h-4 w-4" />
Copy...
</button>
</DropdownMenuItem>
</>
)}
{survey.type === "link" && survey.status !== "draft" && (
@@ -245,6 +201,10 @@ export const SurveyDropDownMenu = ({
text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone."
/>
)}
{isCopyFormOpen && (
<CopySurveyModal open={isCopyFormOpen} setOpen={setIsCopyFormOpen} survey={survey} />
)}
</div>
);
};