mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-28 17:31:08 -06:00
Compare commits
1 Commits
unit-test-
...
feat/copy-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
239f2595ba |
@@ -1,5 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
@@ -19,9 +22,6 @@ import {
|
||||
getSurvey,
|
||||
getSurveys,
|
||||
} from "@/modules/survey/list/lib/survey";
|
||||
import { z } from "zod";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types";
|
||||
|
||||
const ZGetSurveyAction = z.object({
|
||||
surveyId: z.string().cuid2(),
|
||||
@@ -53,6 +53,7 @@ const ZCopySurveyToOtherEnvironmentAction = z.object({
|
||||
environmentId: z.string().cuid2(),
|
||||
surveyId: z.string().cuid2(),
|
||||
targetEnvironmentId: z.string().cuid2(),
|
||||
copyResponses: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
|
||||
@@ -120,7 +121,8 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
|
||||
parsedInput.environmentId,
|
||||
parsedInput.surveyId,
|
||||
parsedInput.targetEnvironmentId,
|
||||
ctx.user.id
|
||||
ctx.user.id,
|
||||
parsedInput.copyResponses
|
||||
);
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertCircleIcon, CopyIcon, DatabaseIcon } from "lucide-react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||
import { TUserProject } from "@/modules/survey/list/types/projects";
|
||||
@@ -8,11 +13,7 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { FormControl, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
|
||||
interface CopySurveyFormProps {
|
||||
readonly defaultProjects: TUserProject[];
|
||||
@@ -107,6 +108,7 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: C
|
||||
project: project.id,
|
||||
environments: [],
|
||||
})),
|
||||
copyMode: "survey_only",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -132,6 +134,7 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: C
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
targetEnvironmentId: environmentId,
|
||||
copyResponses: data.copyMode === "survey_with_responses",
|
||||
}),
|
||||
projectName: project?.name ?? "Unknown Project",
|
||||
environmentType: environment?.type ?? "unknown",
|
||||
@@ -183,6 +186,7 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: C
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error copying survey:", error);
|
||||
toast.error(t("environments.surveys.copy_survey_error"));
|
||||
} finally {
|
||||
setOpen(false);
|
||||
@@ -193,6 +197,42 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: C
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex h-full w-full flex-col bg-white">
|
||||
<div className="flex-1 space-y-8 overflow-y-auto">
|
||||
<div className="mb-6">
|
||||
<Label htmlFor="copyMode">{t("common.copy_options")}</Label>
|
||||
<div className="mt-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="copyMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<OptionsSwitch
|
||||
options={[
|
||||
{
|
||||
value: "survey_only",
|
||||
label: t("environments.surveys.survey_only"),
|
||||
icon: <CopyIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "survey_with_responses",
|
||||
label: t("environments.surveys.survey_with_responses"),
|
||||
icon: <DatabaseIcon className="h-4 w-4" />,
|
||||
},
|
||||
]}
|
||||
currentOption={field.value}
|
||||
handleOptionChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{form.watch("copyMode") === "survey_with_responses"
|
||||
? t("environments.surveys.copy_with_responses_description")
|
||||
: t("environments.surveys.copy_survey_only_description")}
|
||||
</p>
|
||||
</div>
|
||||
{formFields.fields.map((field, projectIndex) => {
|
||||
const project = filteredProjects.find((project) => project.id === field.project);
|
||||
if (!project) return null;
|
||||
|
||||
372
apps/web/modules/survey/list/lib/copy-survey-responses.ts
Normal file
372
apps/web/modules/survey/list/lib/copy-survey-responses.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import "server-only";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { createTag, getTag } from "@/lib/tag/service";
|
||||
|
||||
const STORAGE_URL_PATTERN = /\/storage\/([^/]+)\/(public|private)\/([^/\s]+)/g;
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
interface CopyResponsesResult {
|
||||
copiedCount: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export const extractFileUrlsFromResponseData = (data: Prisma.JsonValue): string[] => {
|
||||
const urls: string[] = [];
|
||||
|
||||
const extractFromValue = (value: any): void => {
|
||||
if (typeof value === "string") {
|
||||
const matches = value.matchAll(STORAGE_URL_PATTERN);
|
||||
for (const match of matches) {
|
||||
urls.push(match[0]);
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach(extractFromValue);
|
||||
} else if (value && typeof value === "object") {
|
||||
Object.values(value).forEach(extractFromValue);
|
||||
}
|
||||
};
|
||||
|
||||
extractFromValue(data);
|
||||
return [...new Set(urls)];
|
||||
};
|
||||
|
||||
export const downloadAndReuploadFile = async (
|
||||
fileUrl: string,
|
||||
sourceEnvironmentId: string,
|
||||
targetEnvironmentId: string
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const match = fileUrl.match(/\/storage\/([^/]+)\/(public|private)\/([^/\s]+)/);
|
||||
if (!match) {
|
||||
logger.error(`Invalid file URL format: ${fileUrl}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, urlEnvironmentId, accessType, fileName] = match;
|
||||
|
||||
if (urlEnvironmentId !== sourceEnvironmentId) {
|
||||
logger.warn(`File URL environment ID mismatch: ${urlEnvironmentId} vs ${sourceEnvironmentId}`);
|
||||
}
|
||||
|
||||
const newFileName = fileName.includes("--fid--")
|
||||
? fileName.replace(/--fid--[^.]+/, `--fid--${createId()}`)
|
||||
: `${fileName}--fid--${createId()}`;
|
||||
|
||||
const newUrl = fileUrl.replace(
|
||||
`/storage/${urlEnvironmentId}/${accessType}/${fileName}`,
|
||||
`/storage/${targetEnvironmentId}/${accessType}/${newFileName}`
|
||||
);
|
||||
|
||||
return newUrl;
|
||||
} catch (error) {
|
||||
logger.error(`Error processing file URL ${fileUrl}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const rewriteFileUrlsInData = (
|
||||
data: Prisma.JsonValue,
|
||||
urlMap: Map<string, string>
|
||||
): Prisma.JsonValue => {
|
||||
const rewriteValue = (value: any): any => {
|
||||
if (typeof value === "string") {
|
||||
let result = value;
|
||||
urlMap.forEach((newUrl, oldUrl) => {
|
||||
result = result.replace(oldUrl, newUrl);
|
||||
});
|
||||
return result;
|
||||
} else if (Array.isArray(value)) {
|
||||
return value.map(rewriteValue);
|
||||
} else if (value && typeof value === "object") {
|
||||
const rewritten: any = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
rewritten[key] = rewriteValue(val);
|
||||
}
|
||||
return rewritten;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return rewriteValue(data);
|
||||
};
|
||||
|
||||
export const mapOrCreateContact = async (
|
||||
sourceContactId: string | null,
|
||||
targetEnvironmentId: string
|
||||
): Promise<string | null> => {
|
||||
if (!sourceContactId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceContact = await prisma.contact.findUnique({
|
||||
where: { id: sourceContactId },
|
||||
include: { attributes: true },
|
||||
});
|
||||
|
||||
if (!sourceContact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let targetContact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
environmentId: targetEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetContact) {
|
||||
targetContact = await prisma.contact.create({
|
||||
data: {
|
||||
environmentId: targetEnvironmentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return targetContact.id;
|
||||
} catch (error) {
|
||||
logger.error(`Error mapping contact ${sourceContactId}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const mapOrCreateTags = async (
|
||||
sourceTagIds: string[],
|
||||
targetEnvironmentId: string
|
||||
): Promise<string[]> => {
|
||||
if (sourceTagIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceTags = await prisma.tag.findMany({
|
||||
where: { id: { in: sourceTagIds } },
|
||||
});
|
||||
|
||||
const targetTagIds: string[] = [];
|
||||
|
||||
for (const sourceTag of sourceTags) {
|
||||
let targetTag = await getTag(targetEnvironmentId, sourceTag.name);
|
||||
|
||||
if (!targetTag) {
|
||||
targetTag = await createTag(targetEnvironmentId, sourceTag.name);
|
||||
}
|
||||
|
||||
targetTagIds.push(targetTag.id);
|
||||
}
|
||||
|
||||
return targetTagIds;
|
||||
} catch (error) {
|
||||
logger.error(`Error mapping tags:`, error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const processResponseFileUrls = async (
|
||||
data: Prisma.JsonValue,
|
||||
sourceEnvironmentId: string,
|
||||
targetEnvironmentId: string
|
||||
): Promise<Prisma.JsonValue> => {
|
||||
const fileUrls = extractFileUrlsFromResponseData(data);
|
||||
const urlMap = new Map<string, string>();
|
||||
|
||||
for (const oldUrl of fileUrls) {
|
||||
const newUrl = await downloadAndReuploadFile(oldUrl, sourceEnvironmentId, targetEnvironmentId);
|
||||
if (newUrl) {
|
||||
urlMap.set(oldUrl, newUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return rewriteFileUrlsInData(data, urlMap);
|
||||
};
|
||||
|
||||
const createResponseQuotaLinks = async (
|
||||
response: any,
|
||||
newResponseId: string,
|
||||
targetSurvey: any
|
||||
): Promise<void> => {
|
||||
if (response.quotaLinks.length === 0 || targetSurvey.quotas.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const quotaNameToIdMap = new Map(targetSurvey.quotas.map((q: any) => [q.name, q.id]));
|
||||
|
||||
const quotaLinksToCreate = response.quotaLinks
|
||||
.map((link: any) => {
|
||||
const targetQuotaId = quotaNameToIdMap.get(link.quota.name);
|
||||
if (targetQuotaId) {
|
||||
return {
|
||||
responseId: newResponseId,
|
||||
quotaId: targetQuotaId,
|
||||
status: link.status,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((link: any): link is NonNullable<typeof link> => link !== null);
|
||||
|
||||
if (quotaLinksToCreate.length > 0) {
|
||||
await prisma.responseQuotaLink.createMany({
|
||||
data: quotaLinksToCreate,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createResponseDisplay = async (
|
||||
response: any,
|
||||
targetSurveyId: string,
|
||||
targetContactId: string | null
|
||||
): Promise<void> => {
|
||||
if (response.display && targetContactId) {
|
||||
await prisma.display.create({
|
||||
data: {
|
||||
surveyId: targetSurveyId,
|
||||
contactId: targetContactId,
|
||||
createdAt: response.display.createdAt,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copySingleResponse = async (
|
||||
response: any,
|
||||
targetSurveyId: string,
|
||||
sourceEnvironmentId: string,
|
||||
targetEnvironmentId: string,
|
||||
targetSurvey: any
|
||||
): Promise<void> => {
|
||||
const rewrittenData = await processResponseFileUrls(
|
||||
response.data,
|
||||
sourceEnvironmentId,
|
||||
targetEnvironmentId
|
||||
);
|
||||
|
||||
const targetContactId = await mapOrCreateContact(response.contactId, targetEnvironmentId);
|
||||
|
||||
const sourceTagIds = response.tags.map((t: any) => t.tag.id);
|
||||
const targetTagIds = await mapOrCreateTags(sourceTagIds, targetEnvironmentId);
|
||||
|
||||
const newResponseId = createId();
|
||||
|
||||
await prisma.response.create({
|
||||
data: {
|
||||
id: newResponseId,
|
||||
surveyId: targetSurveyId,
|
||||
finished: response.finished,
|
||||
data: rewrittenData,
|
||||
variables: response.variables,
|
||||
ttc: response.ttc,
|
||||
meta: response.meta,
|
||||
contactAttributes: response.contactAttributes,
|
||||
contactId: targetContactId,
|
||||
endingId: response.endingId,
|
||||
singleUseId: response.singleUseId,
|
||||
language: response.language,
|
||||
createdAt: response.createdAt,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (targetTagIds.length > 0) {
|
||||
await prisma.tagsOnResponses.createMany({
|
||||
data: targetTagIds.map((tagId) => ({
|
||||
responseId: newResponseId,
|
||||
tagId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
await createResponseQuotaLinks(response, newResponseId, targetSurvey);
|
||||
await createResponseDisplay(response, targetSurveyId, targetContactId);
|
||||
};
|
||||
|
||||
export const copyResponsesForSurvey = async (params: {
|
||||
sourceSurveyId: string;
|
||||
targetSurveyId: string;
|
||||
sourceEnvironmentId: string;
|
||||
targetEnvironmentId: string;
|
||||
batchSize?: number;
|
||||
}): Promise<CopyResponsesResult> => {
|
||||
const {
|
||||
sourceSurveyId,
|
||||
targetSurveyId,
|
||||
sourceEnvironmentId,
|
||||
targetEnvironmentId,
|
||||
batchSize = BATCH_SIZE,
|
||||
} = params;
|
||||
|
||||
const result: CopyResponsesResult = {
|
||||
copiedCount: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const targetSurvey = await prisma.survey.findUnique({
|
||||
where: { id: targetSurveyId },
|
||||
include: { quotas: true },
|
||||
});
|
||||
|
||||
if (!targetSurvey) {
|
||||
throw new ResourceNotFoundError("Target survey", targetSurveyId);
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const responses = await prisma.response.findMany({
|
||||
where: { surveyId: sourceSurveyId },
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
quotaLinks: { include: { quota: true } },
|
||||
display: true,
|
||||
},
|
||||
take: batchSize,
|
||||
skip: offset,
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
if (responses.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const response of responses) {
|
||||
try {
|
||||
await copySingleResponse(
|
||||
response,
|
||||
targetSurveyId,
|
||||
sourceEnvironmentId,
|
||||
targetEnvironmentId,
|
||||
targetSurvey
|
||||
);
|
||||
result.copiedCount++;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
result.errors.push(`Response ${response.id}: ${errorMessage}`);
|
||||
logger.error(`Error copying response ${response.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (responses.length < batchSize) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
offset += batchSize;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Copied ${result.copiedCount} responses from survey ${sourceSurveyId} to ${targetSurveyId}. Errors: ${result.errors.length}`
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Fatal error copying responses:`, error);
|
||||
throw error instanceof DatabaseError ? error : new DatabaseError((error as Error).message);
|
||||
}
|
||||
};
|
||||
@@ -1,13 +1,4 @@
|
||||
import "server-only";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils";
|
||||
import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment";
|
||||
import { getProjectWithLanguagesByEnvironmentId } from "@/modules/survey/list/lib/project";
|
||||
import { TProjectWithLanguages, TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
@@ -16,6 +7,16 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils";
|
||||
import { copyResponsesForSurvey } from "@/modules/survey/list/lib/copy-survey-responses";
|
||||
import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment";
|
||||
import { getProjectWithLanguagesByEnvironmentId } from "@/modules/survey/list/lib/project";
|
||||
import { TProjectWithLanguages, TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
|
||||
export const surveySelect: Prisma.SurveySelect = {
|
||||
id: true,
|
||||
@@ -281,7 +282,8 @@ export const copySurveyToOtherEnvironment = async (
|
||||
environmentId: string,
|
||||
surveyId: string,
|
||||
targetEnvironmentId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
copyResponses: boolean = false
|
||||
) => {
|
||||
try {
|
||||
const isSameEnvironment = environmentId === targetEnvironmentId;
|
||||
@@ -583,6 +585,27 @@ export const copySurveyToOtherEnvironment = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (copyResponses) {
|
||||
try {
|
||||
const copyResult = await copyResponsesForSurvey({
|
||||
sourceSurveyId: surveyId,
|
||||
targetSurveyId: newSurvey.id,
|
||||
sourceEnvironmentId: environmentId,
|
||||
targetEnvironmentId: targetEnvironmentId,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Copied ${copyResult.copiedCount} responses from survey ${surveyId} to ${newSurvey.id}. ${copyResult.errors.length} errors occurred.`
|
||||
);
|
||||
|
||||
if (copyResult.errors.length > 0) {
|
||||
logger.warn(`Errors during response copy: ${copyResult.errors.slice(0, 10).join("; ")}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Error copying responses");
|
||||
}
|
||||
}
|
||||
|
||||
return newSurvey;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -33,6 +33,7 @@ export const ZSurveyCopyFormValidation = z.object({
|
||||
environments: z.array(z.string()),
|
||||
})
|
||||
),
|
||||
copyMode: z.enum(["survey_only", "survey_with_responses"]).default("survey_only"),
|
||||
});
|
||||
|
||||
export type TSurveyCopyFormData = z.infer<typeof ZSurveyCopyFormValidation>;
|
||||
|
||||
Reference in New Issue
Block a user