Compare commits

...

11 Commits

Author SHA1 Message Date
Johannes
7e20e349e8 Merge branch 'main' of https://github.com/formbricks/formbricks into import-from-docx 2026-03-31 17:06:10 +02:00
Johannes
790c207d79 incl. send to remote (single language) 2026-03-31 12:09:25 +02:00
Johannes
d0758f7526 Merge branch 'main' of https://github.com/formbricks/formbricks into feat/import-export 2026-03-30 16:23:48 +02:00
Johannes
902b8c92e2 move to blocks structure, add versioning to exports for better backwards compitability 2025-12-08 22:22:59 +01:00
Johannes
17ba0f21af Merge branch 'main' of https://github.com/formbricks/formbricks into feat/import-export 2025-12-08 14:16:10 +01:00
Johannes
a384743751 surface errors in UI 2025-11-26 16:27:19 +01:00
Johannes
dfa1c3e375 Merge branch 'main' of https://github.com/formbricks/formbricks into feat/import-export 2025-11-26 14:35:46 +01:00
Johannes
77c9302183 Code Rabbit comments 2025-11-20 23:14:46 +01:00
Johannes
88da043c00 remove plan file 2025-11-20 23:02:19 +01:00
Johannes
1cc3ceec55 clean up code 2025-11-20 23:00:07 +01:00
Johannes
50d15f6e07 draft 2025-11-20 13:50:17 +01:00
28 changed files with 3601 additions and 27 deletions

View File

@@ -1,3 +1,4 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
@@ -10,12 +11,114 @@ import {
} from "@/app/lib/api/survey-transformation";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { createLanguage } from "@/lib/language/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProject } from "@/lib/project/service";
import { createSurvey } from "@/lib/survey/service";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getSurveys } from "./lib/surveys";
const ZImportedSurveyLanguage = z.object({
code: z.string().min(1),
enabled: z.boolean(),
default: z.boolean(),
});
const ZImportedSurveyLanguages = z.array(ZImportedSurveyLanguage);
const mapImportedLanguagesToSurveyLanguages = async (
importedLanguages: z.infer<typeof ZImportedSurveyLanguages>,
environmentId: string
) => {
const projectId = await getProjectIdFromEnvironmentId(environmentId);
const uniqueImportedLanguageCodes = [...new Set(importedLanguages.map((language) => language.code))];
let project = await getProject(projectId);
const existingLanguageCodes = new Set(project?.languages.map((language) => language.code) ?? []);
const missingLanguageCodes = uniqueImportedLanguageCodes.filter((code) => !existingLanguageCodes.has(code));
if (missingLanguageCodes.length > 0) {
for (const code of missingLanguageCodes) {
try {
await createLanguage(projectId, { code, alias: null });
} catch {}
}
project = await getProject(projectId);
}
const languagesByCode = new Map((project?.languages ?? []).map((language) => [language.code, language]));
const unresolvedLanguageCodes = uniqueImportedLanguageCodes.filter((code) => !languagesByCode.has(code));
if (unresolvedLanguageCodes.length > 0) {
return {
error: `Import could not auto-create these project languages: ${unresolvedLanguageCodes.join(
", "
)}. Please add them in Project Configuration and try again.`,
};
}
return {
data: importedLanguages.map((language) => ({
language: languagesByCode.get(language.code)!,
enabled: language.enabled,
default: language.default,
})),
};
};
const normalizeSurveyInputForImport = async (
surveyInput: unknown
): Promise<{ data: z.infer<typeof ZSurveyCreateInputWithEnvironmentId> } | { response: Response }> => {
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
if (inputValidation.success) {
return { data: inputValidation.data };
}
const importedLanguagesValidation = z
.object({
environmentId: z.string(),
languages: ZImportedSurveyLanguages,
})
.safeParse(surveyInput);
if (!importedLanguagesValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
const languageMappingResult = await mapImportedLanguagesToSurveyLanguages(
importedLanguagesValidation.data.languages,
importedLanguagesValidation.data.environmentId
);
if ("error" in languageMappingResult) {
return {
response: responses.badRequestResponse(languageMappingResult.error),
};
}
const normalizedInput = {
...(surveyInput as Record<string, unknown>),
languages: languageMappingResult.data,
};
const normalizedInputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(normalizedInput);
if (!normalizedInputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(normalizedInputValidation.error),
true
),
};
}
return { data: normalizedInputValidation.data };
};
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
if (!authentication || !("apiKeyId" in authentication)) {
@@ -83,19 +186,12 @@ export const POST = withV1ApiWrapper({
};
}
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
const normalizedInputResult = await normalizeSurveyInputForImport(surveyInput);
if ("response" in normalizedInputResult) {
return normalizedInputResult;
}
const { environmentId } = inputValidation.data;
const { environmentId } = normalizedInputResult.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
@@ -110,7 +206,7 @@ export const POST = withV1ApiWrapper({
};
}
const surveyData = { ...inputValidation.data, environmentId };
const surveyData = { ...normalizedInputResult.data, environmentId };
const validateResult = validateSurveyInput(surveyData);
if (!validateResult.ok) {

View File

@@ -56,6 +56,7 @@ export const env = createEnv({
OIDC_DISPLAY_NAME: z.string().optional(),
OIDC_ISSUER: z.string().optional(),
OIDC_SIGNING_ALGORITHM: z.string().optional(),
OPENAI_API_KEY: z.string().optional(),
REDIS_URL:
process.env.NODE_ENV === "test"
? z.string().optional()
@@ -124,6 +125,10 @@ export const env = createEnv({
.string()
.transform((val) => parseInt(val))
.optional(),
SURVEY_IMPORT_DESTINATION: z.enum(["local", "remote"]).optional(),
SURVEY_IMPORT_TARGET_API_KEY: z.string().optional(),
SURVEY_IMPORT_TARGET_ENVIRONMENT_ID: z.string().optional(),
SURVEY_IMPORT_TARGET_HOST: z.url().optional(),
SENTRY_ENVIRONMENT: z.string().optional(),
},
@@ -181,6 +186,7 @@ export const env = createEnv({
OIDC_DISPLAY_NAME: process.env.OIDC_DISPLAY_NAME,
OIDC_ISSUER: process.env.OIDC_ISSUER,
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
REDIS_URL: process.env.REDIS_URL,
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
PRIVACY_URL: process.env.PRIVACY_URL,
@@ -220,6 +226,10 @@ export const env = createEnv({
AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED,
AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP,
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
SURVEY_IMPORT_DESTINATION: process.env.SURVEY_IMPORT_DESTINATION,
SURVEY_IMPORT_TARGET_API_KEY: process.env.SURVEY_IMPORT_TARGET_API_KEY,
SURVEY_IMPORT_TARGET_ENVIRONMENT_ID: process.env.SURVEY_IMPORT_TARGET_ENVIRONMENT_ID,
SURVEY_IMPORT_TARGET_HOST: process.env.SURVEY_IMPORT_TARGET_HOST,
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
},
});

View File

@@ -1464,6 +1464,7 @@
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
"everyone": "Everyone",
"expand_preview": "Expand Preview",
"export_survey": "Export Survey",
"external_urls_paywall_tooltip": "Please upgrade to a paid plan to customize external URLs. This helps us prevent phishing.",
"fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1537,6 +1538,54 @@
"ignore_global_waiting_time": "Ignore Cooldown Period",
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
"image": "Image",
"import_docx_languages_detected_item": "{code} ({confidence}% confidence)",
"import_docx_languages_title": "Detected Languages",
"import_docx_notes_title": "Extraction Notes",
"import_error_doc_unsupported": "Legacy .doc files are not supported. Please save the document as .docx first.",
"import_error_docx_convert": "Failed to extract survey from DOCX file",
"import_error_invalid_docx": "Invalid DOCX file",
"import_error_invalid_file": "Unsupported file format. Please upload a .json or .docx file.",
"import_error_invalid_json": "Invalid JSON file",
"import_error_validation": "Survey validation failed",
"import_info_languages_created": "Missing project languages from the import were automatically added to your project configuration.",
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
"import_survey": "Import Survey",
"import_survey_description": "Import a survey from a JSON or DOCX file",
"import_survey_error": "Failed to import survey",
"import_survey_errors": "Errors",
"import_survey_file_label": "Select JSON file",
"import_survey_import": "Import Survey",
"import_survey_loading_phase": {
"encoding": "Packing your file for transport...",
"extracting": "Extracting survey structure with AI...",
"idle": "Preparing import...",
"importing": "Creating your survey in Formbricks...",
"reading": "Reading your file...",
"validating": "Validating survey format..."
},
"import_survey_loading_title": "{verb} your survey...",
"import_survey_loading_verbs": {
"beaming": "Beaming",
"juggling": "Juggling",
"marinating": "Marinating",
"translating": "Translating",
"untangling": "Untangling",
"wizarding": "Wizarding"
},
"import_survey_name_label": "Survey Name",
"import_survey_new_id": "New Survey ID",
"import_survey_preview_json": "Preview generated JSON",
"import_survey_success": "Survey imported successfully",
"import_survey_upload": "Upload File",
"import_survey_validate": "Validating...",
"import_survey_warnings": "Warnings",
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan and might be removed.",
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
"import_warning_multi_language": "Multi-language surveys require an enterprise plan and might be removed.",
"import_warning_recaptcha": "Spam protection requires an enterprise plan and might be disabled.",
"import_warning_segments": "Segment targeting cannot be imported. Configure targeting after import.",
"includes_all_of": "Includes all of",
"includes_one_of": "Includes one of",
"initial_value": "Initial value",
@@ -1837,11 +1886,60 @@
"zip": "Zip"
},
"error_deleting_survey": "An error occurred while deleting survey",
"export_survey": "Export Survey",
"filter": {
"complete_and_partial_responses": "Complete and partial responses",
"complete_responses": "Complete responses",
"partial_responses": "Partial responses"
},
"import_docx_languages_detected_item": "{code} ({confidence}% confidence)",
"import_docx_languages_title": "Detected Languages",
"import_docx_notes_title": "Extraction Notes",
"import_error_doc_unsupported": "Legacy .doc files are not supported. Please save the document as .docx first.",
"import_error_docx_convert": "Failed to extract survey from DOCX file",
"import_error_invalid_docx": "Invalid DOCX file",
"import_error_invalid_file": "Unsupported file format. Please upload a .json or .docx file.",
"import_error_invalid_json": "Invalid JSON file",
"import_error_validation": "Survey validation failed",
"import_info_languages_created": "Missing project languages from the import were automatically added to your project configuration.",
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
"import_survey": "Import Survey",
"import_survey_description": "Import a survey from a JSON or DOCX file",
"import_survey_error": "Failed to import survey",
"import_survey_errors": "Errors",
"import_survey_file_label": "Select JSON file",
"import_survey_import": "Import Survey",
"import_survey_loading_phase": {
"encoding": "Packing your file for transport...",
"extracting": "Extracting survey structure with AI...",
"idle": "Preparing import...",
"importing": "Creating your survey in Formbricks...",
"reading": "Reading your file...",
"validating": "Validating survey format..."
},
"import_survey_loading_title": "{verb} your survey...",
"import_survey_loading_verbs": {
"beaming": "Beaming",
"juggling": "Juggling",
"marinating": "Marinating",
"translating": "Translating",
"untangling": "Untangling",
"wizarding": "Wizarding"
},
"import_survey_name_label": "Survey Name",
"import_survey_new_id": "New Survey ID",
"import_survey_preview_json": "Preview generated JSON",
"import_survey_success": "Survey imported successfully",
"import_survey_upload": "Upload File",
"import_survey_validate": "Validating...",
"import_survey_warnings": "Warnings",
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan. Follow-ups will be removed.",
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
"import_warning_multi_language": "Multi-language surveys require an enterprise plan. Languages will be removed.",
"import_warning_recaptcha": "Spam protection requires an enterprise plan. reCAPTCHA will be disabled.",
"import_warning_segments": "Segment targeting cannot be imported. Configure targeting after import.",
"new_survey": "New Survey",
"no_surveys_created_yet": "No surveys created yet",
"open_options": "Open options",

View File

@@ -19,8 +19,13 @@ export const createSurvey = async (
try {
const { createdBy, ...restSurveyBody } = surveyBody;
// empty languages array
if (!restSurveyBody.languages?.length) {
const hasLanguages = Array.isArray(restSurveyBody.languages)
? restSurveyBody.languages.length > 0
: restSurveyBody.languages &&
typeof restSurveyBody.languages === "object" &&
"create" in restSurveyBody.languages;
if (!hasLanguages) {
delete restSurveyBody.languages;
}

View File

@@ -1,8 +1,13 @@
"use server";
import { createId } from "@paralleldrive/cuid2";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types";
import { env } from "@/lib/env";
import { createLanguage } from "@/lib/language/service";
import { getProject } from "@/lib/project/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
@@ -14,12 +19,34 @@ import {
} from "@/lib/utils/helper";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getSurvey as getSurveyFull } from "@/modules/survey/lib/survey";
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
import { ZSurveyExportPayload, transformSurveyForExport } from "@/modules/survey/list/lib/export-survey";
import {
type TSurveyLanguageConnection,
addLanguageLabels,
mapLanguages,
mapTriggers,
normalizeLanguagesForCreation,
parseSurveyPayload,
persistSurvey,
resolveImportCapabilities,
} from "@/modules/survey/list/lib/import";
import {
buildImportWarnings,
detectImagesInSurvey,
getLanguageNames,
stripUnavailableFeatures,
} from "@/modules/survey/list/lib/import-helpers";
import { convertDocxToSurveyPayload } from "@/modules/survey/list/lib/import/llm-docx-converter";
import { resolveMissingProjectLanguages } from "@/modules/survey/list/lib/import/missing-language-resolution";
import { createRemoteSurveyFromPayload } from "@/modules/survey/list/lib/import/remote-survey-create";
import { getUserProjects } from "@/modules/survey/list/lib/project";
import {
copySurveyToOtherEnvironment,
deleteSurvey,
getSurvey,
getSurvey as getSurveyMinimal,
getSurveys,
} from "@/modules/survey/list/lib/survey";
@@ -46,7 +73,35 @@ export const getSurveyAction = authenticatedActionClient
],
});
return await getSurvey(parsedInput.surveyId);
return await getSurveyMinimal(parsedInput.surveyId);
});
const ZExportSurveyAction = z.object({
surveyId: z.string().cuid2(),
});
export const exportSurveyAction = authenticatedActionClient
.schema(ZExportSurveyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurveyFull(parsedInput.surveyId);
return transformSurveyForExport(survey);
});
const ZCopySurveyToOtherEnvironmentAction = z.object({
@@ -246,3 +301,423 @@ export const getSurveysAction = authenticatedActionClient
parsedInput.filterCriteria
);
});
const ZValidateSurveyImportAction = z.object({
surveyData: ZSurveyExportPayload,
environmentId: z.string().cuid2(),
importRunId: z.string().optional(),
});
export const validateSurveyImportAction = authenticatedActionClient
.schema(ZValidateSurveyImportAction)
.action(async ({ ctx, parsedInput }) => {
logger.info(
{
importRunId: parsedInput.importRunId,
environmentId: parsedInput.environmentId,
userId: ctx.user.id,
},
"Survey import: validating payload"
);
// Step 1: Parse and validate payload structure
const parseResult = parseSurveyPayload(parsedInput.surveyData);
if ("error" in parseResult) {
logger.warn(
{
importRunId: parsedInput.importRunId,
environmentId: parsedInput.environmentId,
userId: ctx.user.id,
parseError: parseResult.error,
parseDetails: parseResult.details,
},
"Survey import: validation failed during payload parsing"
);
return {
valid: false,
errors:
parseResult.details && parseResult.details.length > 0
? [parseResult.error, ...parseResult.details]
: [parseResult.error],
warnings: [],
infos: [],
surveyName:
typeof parsedInput.surveyData === "object" &&
parsedInput.surveyData !== null &&
"data" in parsedInput.surveyData
? ((parsedInput.surveyData.data as { name?: string })?.name ?? "")
: "",
};
}
const { surveyInput, exportedLanguages, triggers } = parseResult;
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
// Trigger validation is now handled by Zod schema validation
const infos: string[] = [];
const languageCodes = exportedLanguages.map((l) => l.code).filter(Boolean);
if (languageCodes.length > 0) {
const project = await getProject(projectId);
const existingLanguageCodes = project?.languages.map((l) => l.code) || [];
let hasManagePermission = true;
try {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "manage",
projectId,
},
],
});
} catch {
hasManagePermission = false;
}
const languageResolution = await resolveMissingProjectLanguages({
importedLanguageCodes: languageCodes,
existingLanguageCodes,
hasManagePermission,
createLanguage: async (code) => {
try {
await createLanguage(projectId, { code, alias: null });
} catch (error) {
logger.warn(
{
importRunId: parsedInput.importRunId,
environmentId: parsedInput.environmentId,
projectId,
languageCode: code,
},
"Survey import: failed to auto-create missing language"
);
logger.warn(error, "Survey import: auto-create missing language error details");
throw error;
}
},
refreshExistingLanguageCodes: async () => {
const refreshedProject = await getProject(projectId);
return refreshedProject?.languages.map((l) => l.code) || [];
},
getLanguageNames,
});
if (languageResolution.errorMessage) {
return {
valid: false,
errors: [languageResolution.errorMessage],
warnings: [],
infos: [],
surveyName: surveyInput.name || "",
};
}
if (languageResolution.createdLanguageCodes.length > 0) {
infos.push("import_info_languages_created");
}
}
const warnings = await buildImportWarnings(surveyInput, organizationId);
const hasImages = detectImagesInSurvey(surveyInput);
if (hasImages) {
warnings.push("import_warning_images");
}
if (triggers && triggers.length > 0) {
infos.push("import_info_triggers");
}
infos.push("import_info_quotas");
logger.info(
{
importRunId: parsedInput.importRunId,
environmentId: parsedInput.environmentId,
warningsCount: warnings.length,
infosCount: infos.length,
questionCount: surveyInput.questions?.length ?? 0,
},
"Survey import: validation completed"
);
return {
valid: true,
errors: [],
warnings,
infos,
surveyName: surveyInput.name || "Imported Survey",
};
});
const ZImportSurveyAction = z.object({
surveyData: ZSurveyExportPayload,
environmentId: z.string().cuid2(),
newName: z.string(),
importRunId: z.string().optional(),
});
const checkImportAuthorization = async (userId: string, environmentId: string): Promise<void> => {
await checkAuthorizationUpdated({
userId,
organizationId: await getOrganizationIdFromEnvironmentId(environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(environmentId),
},
],
});
};
const importSurveyLocally = async ({
userId,
surveyData,
environmentId,
newName,
importRunId,
}: {
userId: string;
surveyData: z.infer<typeof ZSurveyExportPayload>;
environmentId: string;
newName: string;
importRunId?: string;
}): Promise<{ surveyId: string }> => {
logger.info(
{
importRunId,
environmentId,
userId,
newName,
},
"Survey import: starting local import"
);
const parseResult = parseSurveyPayload(surveyData);
if ("error" in parseResult) {
const errorMessage =
parseResult.details && parseResult.details.length > 0
? `${parseResult.error}:\n${parseResult.details.join("\n")}`
: parseResult.error;
throw new Error(`Validation failed: ${errorMessage}`);
}
const { surveyInput, exportedLanguages, triggers } = parseResult;
logger.info(
{
importRunId,
environmentId,
surveyQuestionCount:
(surveyInput.questions?.length ?? 0) +
(surveyInput.blocks?.reduce((count, block) => count + (block.elements?.length ?? 0), 0) ?? 0),
surveyBlockCount: surveyInput.blocks?.length ?? 0,
exportedLanguagesCount: exportedLanguages.length,
triggersCount: triggers.length,
},
"Survey import: parsed local payload"
);
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const capabilities = await resolveImportCapabilities(organizationId);
const triggerResult = await mapTriggers(triggers, environmentId);
const cleanedSurvey = await stripUnavailableFeatures(surveyInput, environmentId);
let mappedLanguages: TSurveyLanguageConnection | undefined = undefined;
let languageCodes: string[] = [];
if (exportedLanguages.length > 0 && capabilities.hasMultiLanguage) {
const projectId = await getProjectIdFromEnvironmentId(environmentId);
const langResult = await mapLanguages(exportedLanguages, projectId);
if (langResult.mapped.length > 0) {
mappedLanguages = normalizeLanguagesForCreation(langResult.mapped);
languageCodes = exportedLanguages.filter((l) => !l.default).map((l) => l.code);
}
}
const surveyWithTranslations = addLanguageLabels(cleanedSurvey, languageCodes);
const result = await persistSurvey(
environmentId,
surveyWithTranslations,
newName,
userId,
triggerResult.mapped,
mappedLanguages
);
logger.info(
{
importRunId,
environmentId,
surveyId: result.surveyId,
},
"Survey import: local import completed"
);
return result;
};
export const importSurveyAction = authenticatedActionClient
.schema(ZImportSurveyAction)
.action(async ({ ctx, parsedInput }) => {
await checkImportAuthorization(ctx.user.id, parsedInput.environmentId);
return await importSurveyLocally({
userId: ctx.user.id,
surveyData: parsedInput.surveyData,
environmentId: parsedInput.environmentId,
newName: parsedInput.newName,
importRunId: parsedInput.importRunId,
});
});
const ZConvertSurveyDocxAction = z.object({
environmentId: z.string().cuid2(),
fileName: z.string().min(1),
fileBase64: z.string().min(1),
});
export const convertSurveyDocxToPayloadAction = authenticatedActionClient
.schema(ZConvertSurveyDocxAction)
.action(async ({ ctx, parsedInput }) => {
const importRunId = createId();
logger.info(
{
importRunId,
environmentId: parsedInput.environmentId,
userId: ctx.user.id,
fileName: parsedInput.fileName,
},
"Survey import: received DOCX conversion request"
);
try {
await checkImportAuthorization(ctx.user.id, parsedInput.environmentId);
let fileBuffer: Buffer;
try {
fileBuffer = Buffer.from(parsedInput.fileBase64, "base64");
} catch {
throw new Error("Invalid file payload");
}
if (fileBuffer.length === 0) {
throw new Error("Uploaded file is empty");
}
const result = await convertDocxToSurveyPayload(fileBuffer, parsedInput.fileName, {
importRunId,
environmentId: parsedInput.environmentId,
userId: ctx.user.id,
});
logger.info(
{
importRunId,
environmentId: parsedInput.environmentId,
outputQuestionCount:
(result.surveyData.data.questions?.length ?? 0) +
(result.surveyData.data.blocks?.reduce(
(count, block) => count + (Array.isArray(block.elements) ? block.elements.length : 0),
0
) ?? 0),
outputBlockCount: result.surveyData.data.blocks?.length ?? 0,
},
"Survey import: DOCX conversion action completed"
);
return {
...result,
importRunId,
};
} catch (error) {
logger.error(
{
importRunId,
environmentId: parsedInput.environmentId,
userId: ctx.user.id,
fileName: parsedInput.fileName,
},
"Survey import: DOCX conversion action failed"
);
logger.error(error, "Survey import: DOCX conversion action error details");
throw error;
}
});
export const importSurveyWithDestinationAction = authenticatedActionClient
.schema(ZImportSurveyAction)
.action(async ({ ctx, parsedInput }) => {
const importRunId = parsedInput.importRunId ?? createId();
logger.info(
{
importRunId,
environmentId: parsedInput.environmentId,
userId: ctx.user.id,
destination: env.SURVEY_IMPORT_DESTINATION ?? "local",
newName: parsedInput.newName,
},
"Survey import: import-with-destination started"
);
try {
await checkImportAuthorization(ctx.user.id, parsedInput.environmentId);
if (env.SURVEY_IMPORT_DESTINATION === "remote") {
return await createRemoteSurveyFromPayload(parsedInput.surveyData, parsedInput.newName, {
importRunId,
requestedByUserId: ctx.user.id,
});
}
return await importSurveyLocally({
userId: ctx.user.id,
surveyData: parsedInput.surveyData,
environmentId: parsedInput.environmentId,
newName: parsedInput.newName,
importRunId,
});
} catch (error) {
logger.error(
{
importRunId,
environmentId: parsedInput.environmentId,
userId: ctx.user.id,
destination: env.SURVEY_IMPORT_DESTINATION ?? "local",
newName: parsedInput.newName,
},
"Survey import: import-with-destination failed"
);
logger.error(error, "Survey import: import-with-destination error details");
throw error;
}
});

View File

@@ -0,0 +1,26 @@
"use client";
import { UploadIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { ImportSurveyModal } from "./import-survey-modal";
interface ImportSurveyButtonProps {
environmentId: string;
}
export const ImportSurveyButton = ({ environmentId }: ImportSurveyButtonProps) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<>
<Button size="sm" variant="secondary" onClick={() => setOpen(true)}>
<UploadIcon className="mr-2 h-4 w-4" />
{t("environments.surveys.import_survey")}
</Button>
<ImportSurveyModal environmentId={environmentId} open={open} setOpen={setOpen} />
</>
);
};

View File

@@ -0,0 +1,516 @@
"use client";
import { ArrowUpFromLineIcon, CheckIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import {
convertSurveyDocxToPayloadAction,
importSurveyWithDestinationAction,
validateSurveyImportAction,
} from "@/modules/survey/list/actions";
import { type TSurveyExportPayload } from "@/modules/survey/list/lib/export-survey";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface ImportSurveyModalProps {
environmentId: string;
open: boolean;
setOpen: (open: boolean) => void;
}
type TImportLoadingPhase = "idle" | "reading" | "encoding" | "extracting" | "validating" | "importing";
interface TDetectedLanguage {
code: string;
confidence: number;
evidence: string[];
}
export const ImportSurveyModal = ({ environmentId, open, setOpen }: ImportSurveyModalProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [fileName, setFileName] = useState<string>("");
const [surveyData, setSurveyData] = useState<TSurveyExportPayload | null>(null);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
const [validationInfos, setValidationInfos] = useState<string[]>([]);
const [newName, setNewName] = useState("");
const [isValid, setIsValid] = useState(false);
const [conversionNotes, setConversionNotes] = useState<string[]>([]);
const [detectedLanguages, setDetectedLanguages] = useState<TDetectedLanguage[]>([]);
const [importRunId, setImportRunId] = useState<string | undefined>(undefined);
const [loadingPhase, setLoadingPhase] = useState<TImportLoadingPhase>("idle");
const [loadingVerbIndex, setLoadingVerbIndex] = useState(0);
const loadingVerbs = [
"beaming",
"marinating",
"untangling",
"juggling",
"translating",
"wizarding",
] as const;
useEffect(() => {
if (!isLoading) {
setLoadingVerbIndex(0);
return;
}
const intervalId = globalThis.setInterval(() => {
setLoadingVerbIndex((current) => (current + 1) % loadingVerbs.length);
}, 1400);
return () => {
globalThis.clearInterval(intervalId);
};
}, [isLoading, loadingVerbs.length]);
const resetState = () => {
setFileName("");
setSurveyData(null);
setValidationErrors([]);
setValidationWarnings([]);
setValidationInfos([]);
setNewName("");
setConversionNotes([]);
setDetectedLanguages([]);
setImportRunId(undefined);
setLoadingPhase("idle");
setIsLoading(false);
setIsValid(false);
};
const onOpenChange = (open: boolean) => {
if (!open) {
resetState();
}
setOpen(open);
};
const isJsonFile = (file: File): boolean =>
file.type === "application/json" || file.name.toLowerCase().endsWith(".json");
const isDocxFile = (file: File): boolean =>
file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
file.name.toLowerCase().endsWith(".docx");
const isLegacyDocFile = (file: File): boolean =>
file.type === "application/msword" || file.name.toLowerCase().endsWith(".doc");
const validateConvertedSurvey = async (payload: TSurveyExportPayload, runId?: string) => {
const result = await validateSurveyImportAction({
surveyData: payload,
environmentId,
importRunId: runId,
});
if (result?.data) {
setValidationErrors(result.data.errors || []);
setValidationWarnings(result.data.warnings || []);
setValidationInfos(result.data.infos || []);
setIsValid(result.data.valid);
if (result.data.valid) {
setNewName(result.data.surveyName + " (imported)");
}
} else if (result?.serverError) {
setValidationErrors([result.serverError]);
setValidationWarnings([]);
setValidationInfos([]);
setIsValid(false);
}
};
const processJSONFile = async (file: File) => {
if (!file) return;
if (!isJsonFile(file)) {
toast.error(t("environments.surveys.import_error_invalid_json"));
setValidationErrors([t("environments.surveys.import_error_invalid_json")]);
setFileName("");
setIsValid(false);
return;
}
setFileName(file.name);
setLoadingPhase("reading");
setIsLoading(true);
try {
const json = JSON.parse(await file.text());
setSurveyData(json);
setImportRunId(() => undefined);
setDetectedLanguages([]);
setLoadingPhase("validating");
await validateConvertedSurvey(json, undefined);
} catch {
toast.error(t("environments.surveys.import_error_invalid_json"));
setValidationErrors([t("environments.surveys.import_error_invalid_json")]);
setValidationWarnings([]);
setValidationInfos([]);
setIsValid(false);
} finally {
setLoadingPhase("idle");
setIsLoading(false);
}
};
const toBase64 = async (file: File): Promise<string> => {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = "";
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCodePoint(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
};
const processDocxFile = async (file: File) => {
if (!file) return;
if (!isDocxFile(file)) {
toast.error(t("environments.surveys.import_error_invalid_docx"));
setValidationErrors([t("environments.surveys.import_error_invalid_docx")]);
setFileName("");
setIsValid(false);
return;
}
setFileName(file.name);
setLoadingPhase("encoding");
setIsLoading(true);
try {
const fileBase64 = await toBase64(file);
setLoadingPhase("extracting");
const conversionResult = await convertSurveyDocxToPayloadAction({
fileBase64,
fileName: file.name,
environmentId,
});
if (!conversionResult?.data) {
throw new Error(conversionResult?.serverError || t("environments.surveys.import_error_docx_convert"));
}
setSurveyData(conversionResult.data.surveyData);
setConversionNotes(conversionResult.data.notes || []);
setDetectedLanguages(conversionResult.data.detectedLanguages || []);
setImportRunId(conversionResult.data.importRunId);
setLoadingPhase("validating");
await validateConvertedSurvey(conversionResult.data.surveyData, conversionResult.data.importRunId);
} catch (error) {
const message =
error instanceof Error ? error.message : t("environments.surveys.import_error_docx_convert");
toast.error(message);
setValidationErrors([message]);
setValidationWarnings([]);
setValidationInfos([]);
setIsValid(false);
} finally {
setLoadingPhase("idle");
setIsLoading(false);
}
};
const processSelectedFile = async (file: File) => {
if (isLegacyDocFile(file) && !isDocxFile(file)) {
const message = t("environments.surveys.import_error_doc_unsupported");
toast.error(message);
setValidationErrors([message]);
setValidationWarnings([]);
setValidationInfos([]);
setFileName("");
setIsValid(false);
return;
}
if (isJsonFile(file)) {
await processJSONFile(file);
return;
}
if (isDocxFile(file)) {
await processDocxFile(file);
return;
}
const message = t("environments.surveys.import_error_invalid_file");
toast.error(message);
setValidationErrors([message]);
setValidationWarnings([]);
setValidationInfos([]);
setFileName("");
setIsValid(false);
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
await processSelectedFile(file);
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
};
const handleDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files[0];
if (file) {
await processSelectedFile(file);
}
};
const handleImport = async () => {
if (!surveyData) {
toast.error(t("environments.surveys.import_survey_error"));
return;
}
setLoadingPhase("importing");
setIsLoading(true);
try {
const result = await importSurveyWithDestinationAction({
surveyData,
environmentId,
newName,
importRunId,
});
if (result?.data) {
toast.success(t("environments.surveys.import_survey_success"));
onOpenChange(false);
globalThis.location.href =
"surveyUrl" in result.data && result.data.surveyUrl
? result.data.surveyUrl
: `/environments/${environmentId}/surveys/${result.data.surveyId}/edit`;
} else if (result?.serverError) {
console.error("[Import Survey] Server error:", result.serverError);
toast.error(result.serverError);
} else {
console.error("[Import Survey] Unknown error - no data or serverError returned");
toast.error(t("environments.surveys.import_survey_error"));
}
} catch (error) {
console.error("[Import Survey] Exception caught:", error);
const errorMessage =
error instanceof Error ? error.message : t("environments.surveys.import_survey_error");
toast.error(errorMessage);
} finally {
setLoadingPhase("idle");
setIsLoading(false);
}
};
const renderUploadSection = () => {
if (isLoading) {
const currentVerb = loadingVerbs[loadingVerbIndex];
return (
<div className="flex flex-col items-center justify-center gap-2 py-8">
<LoadingSpinner />
<p className="text-center text-sm font-medium text-slate-700">
{t("environments.surveys.import_survey_loading_title", {
verb: t(`environments.surveys.import_survey_loading_verbs.${currentVerb}`),
})}
</p>
<p className="text-center text-xs text-slate-500">
{t(`environments.surveys.import_survey_loading_phase.${loadingPhase}`)}
</p>
</div>
);
}
if (!fileName) {
return (
<label
htmlFor="import-file"
className={cn(
"relative flex cursor-pointer flex-col items-center justify-center rounded-lg hover:bg-slate-100"
)}
onDragOver={handleDragOver}
onDrop={handleDrop}>
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
<p className="mt-2 text-center text-sm text-slate-500">
<span className="font-semibold">{t("common.upload_input_description")}</span>
</p>
<p className="text-xs text-slate-400">.json, .docx files only</p>
<Input
id="import-file"
type="file"
accept=".json,.docx,application/json,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
className="hidden"
onChange={handleFileChange}
/>
</div>
</label>
);
}
return (
<div className="flex flex-col items-center gap-4 py-4">
<div className="flex items-center gap-2">
<CheckIcon className="h-5 w-5 text-green-600" />
<span className="text-sm font-medium text-slate-700">{fileName}</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => {
resetState();
document.getElementById("import-file-retry")?.click();
}}>
{t("environments.contacts.upload_contacts_modal_pick_different_file")}
</Button>
<Input
id="import-file-retry"
type="file"
accept=".json,.docx,application/json,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
className="hidden"
onChange={handleFileChange}
/>
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{t("environments.surveys.import_survey")}</DialogTitle>
<DialogDescription>{t("environments.surveys.import_survey_description")}</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="flex flex-col gap-4">
<div className="rounded-md border-2 border-dashed border-slate-300 bg-slate-50 p-4">
{renderUploadSection()}
</div>
{validationErrors.length > 0 && (
<Alert variant="error">
<AlertTitle>{t("environments.surveys.import_survey_errors")}</AlertTitle>
<AlertDescription className="max-h-60 overflow-y-auto">
<ul className="space-y-2 text-sm">
{validationErrors.map((error, i) => {
// Check if the error contains a field path (format: 'Field "path":')
const fieldMatch = /^Field "([^"]+)": (.+)$/.exec(error);
if (fieldMatch) {
return (
<li key={`${fieldMatch[1]}-${i}`} className="flex flex-col gap-1">
<code className="rounded bg-red-50 px-1.5 py-0.5 font-mono text-xs text-red-800">
{fieldMatch[1]}
</code>
<span className="text-slate-700">{fieldMatch[2]}</span>
</li>
);
}
return (
<li key={`${error}-${i}`} className="text-slate-700">
{error}
</li>
);
})}
</ul>
</AlertDescription>
</Alert>
)}
{validationWarnings.length > 0 && (
<Alert variant="warning">
<AlertTitle>{t("environments.surveys.import_survey_warnings")}</AlertTitle>
<AlertDescription className="max-h-60 overflow-y-auto">
<ul className="list-disc pl-4 text-sm">
{validationWarnings.map((warningKey) => (
<li key={warningKey}>{t(`environments.surveys.${warningKey}`)}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{validationInfos.length > 0 && (
<Alert variant="info">
<AlertDescription className="max-h-60 overflow-y-auto">
<ul className="list-disc pl-4 text-sm">
{validationInfos.map((infoKey) => (
<li key={infoKey}>{t(`environments.surveys.${infoKey}`)}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{conversionNotes.length > 0 && (
<Alert variant="info">
<AlertTitle>{t("environments.surveys.import_docx_notes_title")}</AlertTitle>
<AlertDescription className="max-h-60 overflow-y-auto">
<ul className="list-disc pl-4 text-sm">
{conversionNotes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{detectedLanguages.length > 0 && (
<Alert variant="info">
<AlertTitle>{t("environments.surveys.import_docx_languages_title")}</AlertTitle>
<AlertDescription className="max-h-60 overflow-y-auto">
<ul className="list-disc pl-4 text-sm">
{detectedLanguages.map((language) => (
<li key={language.code}>
{t("environments.surveys.import_docx_languages_detected_item", {
code: language.code,
confidence: Math.round(language.confidence * 100),
})}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{isValid && fileName && (
<div className="space-y-2">
<Label htmlFor="survey-name">{t("environments.surveys.import_survey_name_label")}</Label>
<Input id="survey-name" value={newName} onChange={(e) => setNewName(e.target.value)} />
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
{t("common.cancel")}
</Button>
<Button
onClick={handleImport}
loading={isLoading}
disabled={!isValid || !fileName || validationErrors.length > 0 || isLoading}>
{t("environments.surveys.import_survey_import")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -3,6 +3,7 @@
import {
ArrowUpFromLineIcon,
CopyIcon,
DownloadIcon,
EyeIcon,
LinkIcon,
MoreVertical,
@@ -22,8 +23,10 @@ import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import {
copySurveyToOtherEnvironmentAction,
deleteSurveyAction,
exportSurveyAction,
getSurveyAction,
} from "@/modules/survey/list/actions";
import { downloadSurveyJson } from "@/modules/survey/list/lib/download-survey";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
@@ -55,7 +58,7 @@ export const SurveyDropDownMenu = ({
onSurveysCopied,
}: SurveyDropDownMenuProps) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
@@ -77,6 +80,7 @@ export const SurveyDropDownMenu = ({
deleteSurvey(surveyId);
toast.success(t("environments.surveys.survey_deleted_successfully"));
} catch (error) {
logger.error(error);
toast.error(t("environments.surveys.error_deleting_survey"));
} finally {
setLoading(false);
@@ -87,7 +91,6 @@ export const SurveyDropDownMenu = ({
try {
e.preventDefault();
setIsDropDownOpen(false);
// For single-use surveys, this button is disabled, so we just copy the base link
const copiedLink = copySurveyLink(surveyLink);
navigator.clipboard.writeText(copiedLink);
toast.success(t("common.copied_to_clipboard"));
@@ -118,6 +121,7 @@ export const SurveyDropDownMenu = ({
toast.error(errorMessage);
}
} catch (error) {
logger.error(error);
toast.error(t("environments.surveys.survey_duplication_error"));
}
setLoading(false);
@@ -129,6 +133,32 @@ export const SurveyDropDownMenu = ({
setIsCautionDialogOpen(true);
};
const handleExportSurvey = async () => {
const exportPromise = exportSurveyAction({ surveyId: survey.id }).then((result) => {
if (result?.data) {
downloadSurveyJson(survey.name, JSON.stringify(result.data, null, 2));
return result.data;
} else if (result?.serverError) {
throw new Error(result.serverError);
}
throw new Error(t("environments.surveys.export_survey_error"));
});
toast.promise(exportPromise, {
loading: t("environments.surveys.export_survey_loading"),
success: t("environments.surveys.export_survey_success"),
error: (err) => err.message || t("environments.surveys.export_survey_error"),
});
try {
await exportPromise;
} catch (error) {
logger.error(error);
} finally {
setIsDropDownOpen(false);
}
};
return (
<div
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
@@ -189,6 +219,21 @@ export const SurveyDropDownMenu = ({
</button>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
disabled={loading}
onClick={(e) => {
e.preventDefault();
handleExportSurvey();
}}>
<DownloadIcon className="mr-2 h-4 w-4" />
{t("environments.surveys.export_survey")}
</button>
</DropdownMenuItem>
{survey.type === "link" && survey.status !== "draft" && (
<>
<DropdownMenuItem>
@@ -233,7 +278,7 @@ export const SurveyDropDownMenu = ({
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
setIsDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
@@ -248,7 +293,7 @@ export const SurveyDropDownMenu = ({
<DeleteDialog
deleteWhat={t("common.survey")}
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={() => handleDeleteSurvey(survey.id)}
text={t("environments.surveys.delete_survey_and_responses_warning")}
isDeleting={loading}

View File

@@ -0,0 +1,10 @@
export const downloadSurveyJson = (surveyName: string, jsonContent: string) => {
const blob = new Blob([jsonContent], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const timestamp = new Date().toISOString().split("T")[0];
link.href = url;
link.download = `${surveyName}-export-${timestamp}.json`;
link.click();
URL.revokeObjectURL(url);
};

View File

@@ -0,0 +1,146 @@
import { z } from "zod";
import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { ZActionClassNoCodeConfig, ZActionClassType } from "@formbricks/types/action-classes";
import { type TSurvey } from "@formbricks/types/surveys/types";
// Schema for exported action class (subset of full action class)
export const ZExportedActionClass = z.object({
name: z.string(),
description: z.string().nullable(),
type: ZActionClassType,
key: z.string().nullable(),
noCodeConfig: ZActionClassNoCodeConfig.nullable(),
});
export type TExportedActionClass = z.infer<typeof ZExportedActionClass>;
// Schema for exported trigger
export const ZExportedTrigger = z.object({
actionClass: ZExportedActionClass,
});
export type TExportedTrigger = z.infer<typeof ZExportedTrigger>;
// Schema for exported language
export const ZExportedLanguage = z.object({
code: z.string(),
enabled: z.boolean(),
default: z.boolean(),
});
export type TExportedLanguage = z.infer<typeof ZExportedLanguage>;
// Current export format version
export const SURVEY_EXPORT_VERSION = "1.0.0";
// Survey data schema - the actual survey content (nested under "data" in export)
export const ZSurveyExportData = z.object({
// Use the input shape from ZSurveyCreateInput and override what we need
name: z.string(),
type: z.string().optional(),
status: z.string().optional(),
displayOption: z.string().optional(),
environmentId: z.string().optional(),
createdBy: z.string().optional(),
autoClose: z.number().nullable().optional(),
recontactDays: z.number().nullable().optional(),
displayLimit: z.number().nullable().optional(),
delay: z.number().optional(),
displayPercentage: z.number().nullable().optional(),
autoComplete: z.number().nullable().optional(),
isVerifyEmailEnabled: z.boolean().optional(),
isSingleResponsePerEmailEnabled: z.boolean().optional(),
isBackButtonHidden: z.boolean().optional(),
pin: z.string().nullable().optional(),
welcomeCard: z.any().optional(),
questions: z.array(z.any()).optional(),
blocks: z.array(z.any()),
endings: z.array(z.any()).optional(),
hiddenFields: z.any().optional(),
variables: z.array(z.any()).optional(),
surveyClosedMessage: z.any().optional(),
styling: z.any().optional(),
showLanguageSwitch: z.boolean().nullable().optional(),
recaptcha: z.any().optional(),
metadata: z.any().optional(),
triggers: z.array(ZExportedTrigger).default([]),
languages: z.array(ZExportedLanguage).default([]),
followUps: z.array(ZSurveyFollowUp.omit({ createdAt: true, updatedAt: true })).default([]),
});
export type TSurveyExportData = z.infer<typeof ZSurveyExportData>;
// Full export payload with version and metadata wrapper
export const ZSurveyExportPayload = z.object({
version: z.string(),
exportDate: z.string().datetime(),
data: ZSurveyExportData,
});
export type TSurveyExportPayload = z.infer<typeof ZSurveyExportPayload>;
export const transformSurveyForExport = (survey: TSurvey): TSurveyExportPayload => {
const surveyData: TSurveyExportData = {
name: survey.name,
type: survey.type,
status: survey.status,
displayOption: survey.displayOption,
autoClose: survey.autoClose,
recontactDays: survey.recontactDays,
displayLimit: survey.displayLimit,
delay: survey.delay,
displayPercentage: survey.displayPercentage,
autoComplete: survey.autoComplete,
isVerifyEmailEnabled: survey.isVerifyEmailEnabled,
isSingleResponsePerEmailEnabled: survey.isSingleResponsePerEmailEnabled,
isBackButtonHidden: survey.isBackButtonHidden,
pin: survey.pin,
welcomeCard: survey.welcomeCard,
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
surveyClosedMessage: survey.surveyClosedMessage,
styling: survey.styling,
showLanguageSwitch: survey.showLanguageSwitch,
recaptcha: survey.recaptcha,
metadata: survey.metadata,
triggers:
survey.triggers?.map(
(t): TExportedTrigger => ({
actionClass: {
name: t.actionClass.name,
description: t.actionClass.description,
type: t.actionClass.type,
key: t.actionClass.key,
noCodeConfig: t.actionClass.noCodeConfig,
},
})
) ?? [],
languages:
survey.languages?.map(
(l): TExportedLanguage => ({
enabled: l.enabled,
default: l.default,
code: l.language.code,
})
) ?? [],
followUps:
survey.followUps?.map((f) => ({
id: f.id,
surveyId: f.surveyId,
name: f.name,
trigger: f.trigger,
action: f.action,
})) ?? [],
};
return {
version: SURVEY_EXPORT_VERSION,
exportDate: new Date().toISOString(),
data: surveyData,
};
};

View File

@@ -0,0 +1,110 @@
import { iso639Languages } from "@formbricks/i18n-utils/src/utils";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { type TExportedLanguage, type TExportedTrigger } from "./export-survey";
import {
type TMappedTrigger,
type TSurveyLanguageConnection,
mapLanguages,
mapTriggers,
normalizeLanguagesForCreation,
resolveImportCapabilities,
stripUnavailableFeatures as stripFeatures,
} from "./import";
export const getLanguageNames = (languageCodes: string[]): string[] => {
return languageCodes.map((code) => {
const language = iso639Languages.find((lang) => lang.alpha2 === code);
return language ? language.label["en-US"] : code;
});
};
export const mapExportedLanguagesToPrismaCreate = async (
exportedLanguages: TExportedLanguage[],
projectId: string
): Promise<TSurveyLanguageConnection | undefined> => {
const result = await mapLanguages(exportedLanguages, projectId);
return normalizeLanguagesForCreation(result.mapped);
};
export const mapOrCreateActionClasses = async (
importedTriggers: TExportedTrigger[],
environmentId: string
): Promise<TMappedTrigger[]> => {
const result = await mapTriggers(importedTriggers, environmentId);
return result.mapped;
};
export const stripUnavailableFeatures = async (
survey: TSurveyCreateInput,
environmentId: string
): Promise<TSurveyCreateInput> => {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const capabilities = await resolveImportCapabilities(organizationId);
return stripFeatures(survey, capabilities);
};
export const buildImportWarnings = async (
survey: TSurveyCreateInput,
organizationId: string
): Promise<string[]> => {
const warnings: string[] = [];
if (survey.followUps?.length) {
let hasFollowUps = false;
try {
const organizationBilling = await getOrganizationBilling(organizationId);
if (organizationBilling) {
hasFollowUps = await getSurveyFollowUpsPermission(organizationBilling.plan);
}
} catch (e) {}
if (!hasFollowUps) {
warnings.push("import_warning_follow_ups");
}
}
if (survey.recaptcha?.enabled) {
try {
await checkSpamProtectionPermission(organizationId);
} catch (e) {
warnings.push("import_warning_recaptcha");
}
}
if (survey.segment) {
warnings.push("import_warning_segments");
}
if (survey.triggers?.length) {
warnings.push("import_warning_action_classes");
}
return warnings;
};
export const detectImagesInSurvey = (survey: TSurveyCreateInput): boolean => {
if (survey.welcomeCard?.fileUrl || survey.welcomeCard?.videoUrl) return true;
// Check blocks for images
if (survey.blocks) {
for (const block of survey.blocks) {
for (const element of block.elements) {
if (element.imageUrl || element.videoUrl) return true;
if (element.type === "pictureSelection" && element.choices?.some((c) => c.imageUrl)) {
return true;
}
}
}
}
if (survey.endings && survey.endings.length > 0) {
for (const e of survey.endings) {
if (e.type === "endScreen" && (e.imageUrl || e.videoUrl)) return true;
}
}
return false;
};

View File

@@ -0,0 +1,11 @@
export { mapLanguages, type TMappedLanguage } from "./map-languages";
export { mapTriggers, type TMappedTrigger } from "./map-triggers";
export {
addLanguageLabels,
normalizeLanguagesForCreation,
stripUnavailableFeatures,
type TSurveyLanguageConnection,
} from "./normalize-survey";
export { parseSurveyPayload, type TParsedPayload } from "./parse-payload";
export { resolveImportCapabilities, type TImportCapabilities } from "./permissions";
export { persistSurvey } from "./persist-survey";

View File

@@ -0,0 +1,288 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { convertDocxToSurveyPayload } from "./llm-docx-converter";
const byLanguage = (value: Record<string, string>) =>
Object.entries(value).map(([languageCode, text]) => ({ languageCode, text }));
const { mockParse, mockExtractRawText } = vi.hoisted(() => ({
mockParse: vi.fn(),
mockExtractRawText: vi.fn(),
}));
vi.mock("@/lib/env", () => ({
env: {
OPENAI_API_KEY: "test-openai-key",
},
}));
vi.mock("openai", () => ({
default: class OpenAI {
responses = {
parse: mockParse,
};
},
}));
vi.mock("mammoth", () => ({
default: {
extractRawText: mockExtractRawText,
},
}));
describe("convertDocxToSurveyPayload", () => {
beforeEach(() => {
mockParse.mockReset();
mockExtractRawText.mockReset();
});
test("converts extracted DOCX into multilingual survey export payload", async () => {
vi.mocked(mockExtractRawText).mockResolvedValue({
value: "Customer Satisfaction Survey",
});
vi.mocked(mockParse)
.mockResolvedValueOnce({
output_parsed: {
languages: [
{ code: "english", confidence: 0.98, evidence: ["Question titles"] },
{ code: "de-DE", confidence: 0.92, evidence: ["Section heading"] },
],
primaryLanguageCode: "en-US",
isAmbiguous: false,
ambiguityReasons: [],
},
})
.mockResolvedValueOnce({
output_parsed: {
surveyTitle: byLanguage({
en: "CSAT Survey",
de: "CSAT Umfrage",
}),
intro: byLanguage({
en: "Please answer a few questions.",
de: "Bitte beantworten Sie einige Fragen.",
}),
outro: byLanguage({
en: "Thanks for your feedback.",
de: "Vielen Dank fur Ihr Feedback.",
}),
notes: ["Question 2 inferred as single choice."],
questions: [
{
id: "satisfaction",
type: "singleChoice",
title: byLanguage({
en: "How satisfied are you?",
de: "Wie zufrieden sind Sie?",
}),
description: null,
required: true,
options: [
byLanguage({ en: "Very satisfied", de: "Sehr zufrieden" }),
byLanguage({ en: "Satisfied", de: "Zufrieden" }),
byLanguage({ en: "Not satisfied", de: "Nicht zufrieden" }),
],
allowOther: true,
lowerLabel: null,
upperLabel: null,
},
{
id: "comments",
type: "openText",
title: byLanguage({
en: "Any additional comments?",
de: "Weitere Kommentare?",
}),
description: byLanguage({
en: "Optional",
de: "Optional",
}),
required: false,
options: [],
allowOther: false,
lowerLabel: null,
upperLabel: null,
},
{
id: "nps_score",
type: "nps",
title: byLanguage({
en: "How likely are you to recommend us?",
de: "Wie wahrscheinlich ist eine Empfehlung?",
}),
description: null,
required: true,
options: [],
allowOther: false,
lowerLabel: byLanguage({
en: "0 Not likely",
de: "0 Unwahrscheinlich",
}),
upperLabel: byLanguage({
en: "10 Extremely likely",
de: "10 Sehr wahrscheinlich",
}),
},
{
id: "rating",
type: "rating",
title: byLanguage({
en: "Rate your overall experience",
de: "Bewerten Sie Ihre Erfahrung",
}),
description: null,
required: true,
options: [],
allowOther: false,
lowerLabel: byLanguage({
en: "1 - Very bad",
de: "1 - Sehr schlecht",
}),
upperLabel: byLanguage({
en: "5 = Very good",
de: "5 = Sehr gut",
}),
},
],
},
});
const result = await convertDocxToSurveyPayload(Buffer.from("docx-content"), "survey.docx");
expect(result.notes).toContain("Question 2 inferred as single choice.");
expect(result.notes.some((note) => note.includes("Detected languages: en"))).toBe(true);
expect(result.surveyData.version).toBe("1.0.0");
expect(result.surveyData.data.name).toBe("CSAT Survey");
expect(result.detectedLanguages).toEqual([
{ code: "en", confidence: 0.98, evidence: ["Question titles"] },
{ code: "de", confidence: 0.92, evidence: ["Section heading"] },
]);
expect(result.surveyData.data.languages).toEqual([
{ code: "en", enabled: true, default: true },
{ code: "de", enabled: true, default: false },
]);
expect(result.surveyData.data.questions).toHaveLength(0);
expect(result.surveyData.data.blocks).toHaveLength(4);
expect(result.surveyData.data.blocks[0].elements[0].type).toBe("multipleChoiceSingle");
expect(result.surveyData.data.blocks[0].elements[0].headline).toEqual({
default: "How satisfied are you?",
de: "Wie zufrieden sind Sie?",
});
expect(result.surveyData.data.blocks[0].elements[0].choices.at(-1)?.id).toBe("other");
expect(result.surveyData.data.blocks[0].elements[0].otherOptionPlaceholder).toEqual({
default: "Please specify",
de: "",
});
expect(result.surveyData.data.blocks[1].elements[0].type).toBe("openText");
expect(result.surveyData.data.blocks[2].elements[0].type).toBe("nps");
expect(result.surveyData.data.blocks[2].elements[0].lowerLabel).toEqual({
default: "Not likely",
de: "Unwahrscheinlich",
});
expect(result.surveyData.data.blocks[2].elements[0].upperLabel).toEqual({
default: "Extremely likely",
de: "Sehr wahrscheinlich",
});
expect(result.surveyData.data.blocks[3].elements[0].type).toBe("rating");
expect(result.surveyData.data.blocks[3].elements[0].lowerLabel).toEqual({
default: "Very bad",
de: "Sehr schlecht",
});
expect(result.surveyData.data.blocks[3].elements[0].upperLabel).toEqual({
default: "Very good",
de: "Sehr gut",
});
});
test("rejects non-docx files", async () => {
await expect(convertDocxToSurveyPayload(Buffer.from("x"), "survey.doc")).rejects.toThrow(
"Only .docx files are supported"
);
});
test("throws when no text can be extracted", async () => {
vi.mocked(mockExtractRawText).mockResolvedValue({ value: " " });
await expect(convertDocxToSurveyPayload(Buffer.from("docx-content"), "survey.docx")).rejects.toThrow(
"Could not extract text from document"
);
});
test("throws when model does not return parsed output", async () => {
vi.mocked(mockExtractRawText).mockResolvedValue({ value: "Survey content" });
vi.mocked(mockParse).mockResolvedValue({ output_parsed: null });
await expect(convertDocxToSurveyPayload(Buffer.from("docx-content"), "survey.docx")).rejects.toThrow(
"did not return structured language detection data"
);
});
test("throws when language detection is ambiguous", async () => {
vi.mocked(mockExtractRawText).mockResolvedValue({ value: "Survey content" });
vi.mocked(mockParse).mockResolvedValueOnce({
output_parsed: {
languages: [{ code: "en", confidence: 0.4, evidence: [] }],
primaryLanguageCode: "en",
isAmbiguous: true,
ambiguityReasons: ["Mixed language hints"],
},
});
await expect(convertDocxToSurveyPayload(Buffer.from("docx-content"), "survey.docx")).rejects.toThrow(
"Language detection is ambiguous"
);
});
test("accepts confidently detected parallel bilingual surveys", async () => {
vi.mocked(mockExtractRawText).mockResolvedValue({ value: "English and Arabic survey content" });
vi.mocked(mockParse)
.mockResolvedValueOnce({
output_parsed: {
languages: [
{ code: "english", confidence: 0.97, evidence: ["Section EN"] },
{ code: "arabic", confidence: 0.95, evidence: ["Section AR"] },
],
primaryLanguageCode: "english",
isAmbiguous: true,
ambiguityReasons: ["Parallel language versions"],
},
})
.mockResolvedValueOnce({
output_parsed: {
surveyTitle: byLanguage({
en: "Customer Feedback",
ar: "ملاحظات العملاء",
}),
intro: null,
outro: null,
notes: [],
questions: [
{
id: "q1",
type: "openText",
title: byLanguage({
en: "How can we improve?",
ar: "كيف يمكننا التحسين؟",
}),
description: null,
required: true,
options: [],
allowOther: false,
lowerLabel: null,
upperLabel: null,
},
],
},
});
const result = await convertDocxToSurveyPayload(Buffer.from("docx-content"), "survey.docx");
expect(result.detectedLanguages).toEqual([
{ code: "en", confidence: 0.97, evidence: ["Section EN"] },
{ code: "ar", confidence: 0.95, evidence: ["Section AR"] },
]);
expect(result.surveyData.data.languages).toEqual([
{ code: "en", enabled: true, default: true },
{ code: "ar", enabled: true, default: false },
]);
});
});

View File

@@ -0,0 +1,779 @@
import { createId } from "@paralleldrive/cuid2";
import mammoth from "mammoth";
import OpenAI from "openai";
import { zodTextFormat } from "openai/helpers/zod";
import { z } from "zod";
import { iso639Languages } from "@formbricks/i18n-utils/src/utils";
import { logger } from "@formbricks/logger";
import { env } from "@/lib/env";
import { SURVEY_EXPORT_VERSION, type TSurveyExportData, type TSurveyExportPayload } from "../export-survey";
const ZLLMQuestionType = z.enum(["openText", "singleChoice", "multiChoice", "nps", "rating"]);
const ZLLMTextEntry = z.object({
languageCode: z.string().min(1),
text: z.string(),
});
const ZLLMTextByLanguage = z.array(ZLLMTextEntry).default([]);
const ZLLMSurveyQuestion = z.object({
id: z.string().min(1),
type: ZLLMQuestionType,
title: ZLLMTextByLanguage,
description: ZLLMTextByLanguage.nullable().default(null),
required: z.boolean().default(false),
options: z.array(ZLLMTextByLanguage).default([]),
allowOther: z.boolean().default(false),
lowerLabel: ZLLMTextByLanguage.nullable().default(null),
upperLabel: ZLLMTextByLanguage.nullable().default(null),
});
const ZLLMDetectedLanguage = z.object({
code: z.string().min(1),
confidence: z.number().min(0).max(1),
evidence: z.array(z.string()).default([]),
});
const ZLLMLanguageDetection = z.object({
languages: z.array(ZLLMDetectedLanguage).min(1),
primaryLanguageCode: z.string().nullable().default(null),
isAmbiguous: z.boolean().default(false),
ambiguityReasons: z.array(z.string()).default([]),
});
const ZLLMSurveyExtraction = z.object({
surveyTitle: ZLLMTextByLanguage,
intro: ZLLMTextByLanguage.nullable().default(null),
outro: ZLLMTextByLanguage.nullable().default(null),
questions: z.array(ZLLMSurveyQuestion).min(1),
notes: z.array(z.string()).default([]),
});
type TLLMSurveyExtraction = z.infer<typeof ZLLMSurveyExtraction>;
type TLLMLanguageDetection = z.infer<typeof ZLLMLanguageDetection>;
type TLocalizedText = { default: string; [languageCode: string]: string };
interface TDetectedLanguageResult {
code: string;
confidence: number;
evidence: string[];
}
export interface TDocxConversionResult {
surveyData: TSurveyExportPayload;
notes: string[];
detectedLanguages: TDetectedLanguageResult[];
}
export interface TDocxConversionContext {
importRunId?: string;
environmentId?: string;
userId?: string;
}
const LANGUAGE_DETECTION_SYSTEM_PROMPT = `
You detect languages used in survey content extracted from DOCX.
Rules:
- Detect only languages clearly present in user-facing survey text.
- Use ISO 639-1 two-letter codes in "code" whenever possible.
- Include confidence between 0 and 1.
- Mark isAmbiguous=true if language identity is uncertain or mixed.
- Add concise reasons in ambiguityReasons when ambiguous.
- Return strict JSON matching schema.
`.trim();
const SEMANTIC_EXTRACTION_SYSTEM_PROMPT = `
You convert survey text from a .docx document into structured multilingual survey data.
Rules:
- Extract only questions explicitly present in the input.
- Do not invent extra questions, options, logic rules, or metadata.
- If a question type is unclear, default to openText.
- Use only these types: openText, singleChoice, multiChoice, nps, rating.
- For singleChoice and multiChoice, include at least two options if available.
- If a choice question contains "Other (please specify)" (or equivalent), set allowOther=true and do not add "Other" into options.
- For nps and rating, put scale anchors into lowerLabel and upperLabel fields (not in description), when present.
- Return multilingual text arrays for all translatable fields using items:
{ "languageCode": "<code>", "text": "<localized text>" }.
- Only use the language codes supplied by the user message.
- Keep wording close to source text.
- Return strict JSON matching schema.
`.trim();
const LANGUAGE_CONFIDENCE_THRESHOLD = 0.6;
const languageAliasMap: Record<string, string> = {
english: "en",
german: "de",
deutsch: "de",
french: "fr",
spanish: "es",
portuguese: "pt",
italian: "it",
dutch: "nl",
japanese: "ja",
chinese: "zh",
swedish: "sv",
russian: "ru",
arabic: "ar",
};
const getOpenAIClient = (): OpenAI => {
if (!env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY is missing");
}
return new OpenAI({ apiKey: env.OPENAI_API_KEY });
};
const extractRawTextFromDocx = async (fileBuffer: Buffer): Promise<string> => {
const result = await mammoth.extractRawText({ buffer: fileBuffer });
const text = result.value.trim();
if (!text) {
throw new Error("Could not extract text from document");
}
return text;
};
const normalizeLanguageCode = (rawCode: string): string | null => {
const normalized = rawCode.trim().toLowerCase();
if (!normalized) {
return null;
}
const aliasResolved = languageAliasMap[normalized] ?? normalized;
const baseCode = aliasResolved.split(/[-_]/)[0];
if (!baseCode) {
return null;
}
const fromIsoList = iso639Languages.find((language) => {
const alpha2 = language.alpha2?.toLowerCase();
const code = language.code?.toLowerCase();
const englishLabel = language.label?.["en-US"]?.toLowerCase();
return alpha2 === baseCode || code === baseCode || englishLabel === aliasResolved;
});
return fromIsoList?.alpha2?.toLowerCase() ?? baseCode;
};
const normalizeTextByLanguage = (
value: Array<{ languageCode: string; text: string }> | null | undefined
): Record<string, string> => {
if (!value) {
return {};
}
const normalized: Record<string, string> = {};
for (const entry of value) {
const rawLanguageCode = entry.languageCode;
const text = entry.text;
const languageCode = normalizeLanguageCode(rawLanguageCode);
if (!languageCode) {
continue;
}
const trimmed = text.trim();
if (!trimmed) {
continue;
}
if (!(languageCode in normalized)) {
normalized[languageCode] = trimmed;
}
}
return normalized;
};
const toLocalizedText = ({
value,
languageCodes,
defaultLanguageCode,
required,
fieldLabel,
}: {
value: Array<{ languageCode: string; text: string }> | null | undefined;
languageCodes: string[];
defaultLanguageCode: string;
required: boolean;
fieldLabel: string;
}): TLocalizedText => {
const normalizedValue = normalizeTextByLanguage(value);
const fallbackDefault = Object.values(normalizedValue).find((entry) => entry.trim().length > 0);
const defaultText = normalizedValue[defaultLanguageCode] ?? fallbackDefault ?? "";
if (required && defaultText.trim().length === 0) {
throw new Error(`Missing required translation for ${fieldLabel} (${defaultLanguageCode})`);
}
const localized: TLocalizedText = { default: defaultText };
for (const languageCode of languageCodes) {
if (languageCode === defaultLanguageCode) {
continue;
}
const translatedValue = normalizedValue[languageCode] ?? "";
if (required && translatedValue.trim().length === 0) {
throw new Error(`Missing required translation for ${fieldLabel} (${languageCode})`);
}
localized[languageCode] = translatedValue;
}
return localized;
};
const sanitizeDetectedLanguages = (detection: TLLMLanguageDetection) => {
const mergedLanguages = new Map<string, TDetectedLanguageResult>();
for (const detectedLanguage of detection.languages) {
const normalizedCode = normalizeLanguageCode(detectedLanguage.code);
if (!normalizedCode) {
continue;
}
const existing = mergedLanguages.get(normalizedCode);
if (!existing) {
mergedLanguages.set(normalizedCode, {
code: normalizedCode,
confidence: detectedLanguage.confidence,
evidence: [...detectedLanguage.evidence],
});
continue;
}
mergedLanguages.set(normalizedCode, {
code: normalizedCode,
confidence: Math.max(existing.confidence, detectedLanguage.confidence),
evidence: [...new Set([...existing.evidence, ...detectedLanguage.evidence])],
});
}
const languages = [...mergedLanguages.values()].sort((a, b) => b.confidence - a.confidence);
if (languages.length === 0) {
throw new Error("No supported languages could be detected in the DOCX survey content");
}
const normalizedPrimaryLanguageCode = detection.primaryLanguageCode
? normalizeLanguageCode(detection.primaryLanguageCode)
: null;
const primaryLanguageCode = normalizedPrimaryLanguageCode ?? languages[0].code;
const ambiguityReasons = [...detection.ambiguityReasons];
const topConfidence = languages[0]?.confidence ?? 0;
if (topConfidence < LANGUAGE_CONFIDENCE_THRESHOLD) {
ambiguityReasons.push(
`Top language confidence (${topConfidence.toFixed(2)}) is below threshold (${LANGUAGE_CONFIDENCE_THRESHOLD.toFixed(2)})`
);
}
const highConfidenceLanguageCount = languages.filter(
(language) => language.confidence >= LANGUAGE_CONFIDENCE_THRESHOLD
).length;
const canResolveParallelLanguageVariants =
detection.isAmbiguous && languages.length >= 2 && highConfidenceLanguageCount >= 2;
if (detection.isAmbiguous && !canResolveParallelLanguageVariants) {
if (ambiguityReasons.length === 0) {
ambiguityReasons.push("Model flagged language detection as ambiguous");
}
}
return {
languages,
primaryLanguageCode,
isAmbiguous:
detection.isAmbiguous && !canResolveParallelLanguageVariants ? ambiguityReasons.length > 0 : false,
ambiguityReasons,
};
};
const sanitizeQuestionId = (rawId: string, fallbackIndex: number): string => {
const sanitized = rawId
.toLowerCase()
.trim()
.replaceAll(/\s+/g, "_")
.replaceAll(/[^a-z0-9_-]/g, "");
if (!sanitized) {
return `q_${fallbackIndex + 1}`;
}
return sanitized;
};
const ensureUniqueQuestionIds = (questions: TLLMSurveyExtraction["questions"]) => {
const usedIds = new Set<string>();
return questions.map((question, index) => {
let candidate = sanitizeQuestionId(question.id, index);
let suffix = 2;
while (usedIds.has(candidate)) {
candidate = `${sanitizeQuestionId(question.id, index)}_${suffix}`;
suffix += 1;
}
usedIds.add(candidate);
return { ...question, id: candidate };
});
};
const normalizeScaleAnchorLabel = (label: string | null): string | null => {
if (!label) {
return null;
}
const cleanedLabel = label
.trim()
// Remove leading number tokens with optional bracket and separator.
.replace(/^\s*(?:\(\d{1,2}\)|\[\d{1,2}\]|\d{1,2})\s*[:=-]?\s*/u, "")
.trim();
return cleanedLabel.length > 0 ? cleanedLabel : null;
};
const normalizeLocalizedScaleLabel = (value: TLocalizedText): TLocalizedText | undefined => {
const normalized: TLocalizedText = { default: value.default };
for (const [languageCode, label] of Object.entries(value)) {
const cleanedLabel = normalizeScaleAnchorLabel(label);
if (!cleanedLabel) {
if (languageCode === "default") {
return undefined;
}
normalized[languageCode] = "";
continue;
}
normalized[languageCode] = cleanedLabel;
}
if (!normalized.default || normalized.default.trim().length === 0) {
return undefined;
}
return normalized;
};
const getScaleLabels = ({
question,
languageCodes,
defaultLanguageCode,
fieldPrefix,
}: {
question: TLLMSurveyExtraction["questions"][number];
languageCodes: string[];
defaultLanguageCode: string;
fieldPrefix: string;
}) => {
const lowerLabel = normalizeLocalizedScaleLabel(
toLocalizedText({
value: question.lowerLabel,
languageCodes,
defaultLanguageCode,
required: false,
fieldLabel: `${fieldPrefix}.lowerLabel`,
})
);
const upperLabel = normalizeLocalizedScaleLabel(
toLocalizedText({
value: question.upperLabel,
languageCodes,
defaultLanguageCode,
required: false,
fieldLabel: `${fieldPrefix}.upperLabel`,
})
);
return {
lowerLabel,
upperLabel,
};
};
const getMultipleChoiceConfig = ({
question,
languageCodes,
defaultLanguageCode,
fieldPrefix,
}: {
question: TLLMSurveyExtraction["questions"][number];
languageCodes: string[];
defaultLanguageCode: string;
fieldPrefix: string;
}) => {
const normalizedOptions = question.options
.slice(0, 25)
.map((option, index) =>
toLocalizedText({
value: option,
languageCodes,
defaultLanguageCode,
required: true,
fieldLabel: `${fieldPrefix}.options[${index}]`,
})
)
.filter((option) => !/^other(\s*\(.*\))?$/i.test(option.default.trim()));
const choices = normalizedOptions.map((option) => ({
id: createId(),
label: option,
}));
if (question.allowOther) {
const otherLabel: TLocalizedText = { default: "Other" };
const otherPlaceholder: TLocalizedText = { default: "Please specify" };
for (const languageCode of languageCodes) {
if (languageCode === defaultLanguageCode) {
continue;
}
otherLabel[languageCode] = "";
otherPlaceholder[languageCode] = "";
}
choices.push({
id: "other",
label: otherLabel,
});
return {
choices,
otherOptionPlaceholder: otherPlaceholder,
};
}
return {
choices,
otherOptionPlaceholder: undefined,
};
};
const mapToExportData = (
llmData: TLLMSurveyExtraction,
languageCodes: string[],
defaultLanguageCode: string
): TSurveyExportData => {
const surveyName = toLocalizedText({
value: llmData.surveyTitle,
languageCodes,
defaultLanguageCode,
required: true,
fieldLabel: "surveyTitle",
}).default;
const questionElements = ensureUniqueQuestionIds(llmData.questions).map((question) => {
const fieldPrefix = `question.${question.id}`;
const baseQuestion = {
id: question.id,
headline: toLocalizedText({
value: question.title,
languageCodes,
defaultLanguageCode,
required: true,
fieldLabel: `${fieldPrefix}.title`,
}),
subheader: question.description
? toLocalizedText({
value: question.description,
languageCodes,
defaultLanguageCode,
required: false,
fieldLabel: `${fieldPrefix}.description`,
})
: undefined,
required: question.required,
};
switch (question.type) {
case "openText":
return {
...baseQuestion,
type: "openText",
inputType: "text" as const,
charLimit: { enabled: false },
};
case "singleChoice":
case "multiChoice":
return {
...baseQuestion,
type: question.type === "singleChoice" ? "multipleChoiceSingle" : "multipleChoiceMulti",
...getMultipleChoiceConfig({
question,
languageCodes,
defaultLanguageCode,
fieldPrefix,
}),
};
case "nps":
return {
...baseQuestion,
type: "nps",
...getScaleLabels({
question,
languageCodes,
defaultLanguageCode,
fieldPrefix,
}),
};
case "rating":
return {
...baseQuestion,
type: "rating",
scale: "number" as const,
range: 5 as const,
...getScaleLabels({
question,
languageCodes,
defaultLanguageCode,
fieldPrefix,
}),
};
default:
throw new Error(`Unsupported question type: ${String(question.type)}`);
}
});
const blocks = questionElements.map((element, index) => ({
id: createId(),
name: `Question ${index + 1}`,
elements: [element],
}));
return {
name: surveyName,
type: "link",
questions: [],
blocks,
welcomeCard: {
enabled: Boolean(llmData.intro && Object.keys(llmData.intro).length > 0),
headline: llmData.intro
? toLocalizedText({
value: llmData.intro,
languageCodes,
defaultLanguageCode,
required: false,
fieldLabel: "intro",
})
: undefined,
timeToFinish: true,
showResponseCount: false,
},
endings: [
{
id: createId(),
type: "endScreen",
headline: llmData.outro
? toLocalizedText({
value: llmData.outro,
languageCodes,
defaultLanguageCode,
required: false,
fieldLabel: "outro",
})
: {
default: "Thank you!",
...Object.fromEntries(
languageCodes.filter((code) => code !== defaultLanguageCode).map((code) => [code, ""])
),
},
},
],
triggers: [],
languages: languageCodes.map((code) => ({
code,
enabled: true,
default: code === defaultLanguageCode,
})),
followUps: [],
showLanguageSwitch: languageCodes.length > 1,
};
};
const detectSurveyLanguages = async (
openai: OpenAI,
extractedText: string
): Promise<{
languages: TDetectedLanguageResult[];
primaryLanguageCode: string;
ambiguityReasons: string[];
}> => {
const languageResponse = await openai.responses.parse({
model: "gpt-4.1",
input: [
{
role: "system",
content: LANGUAGE_DETECTION_SYSTEM_PROMPT,
},
{
role: "user",
content: `Detect languages from this survey document text and return strict JSON:\n\n${extractedText}`,
},
],
text: {
format: zodTextFormat(ZLLMLanguageDetection, "survey_docx_language_detection"),
},
});
const parsedLanguageOutput = languageResponse.output_parsed;
if (!parsedLanguageOutput) {
throw new Error("The model did not return structured language detection data");
}
const normalizedLanguageDetection = sanitizeDetectedLanguages(parsedLanguageOutput);
if (normalizedLanguageDetection.isAmbiguous) {
throw new Error(
`Language detection is ambiguous: ${normalizedLanguageDetection.ambiguityReasons.join("; ")}`
);
}
return {
languages: normalizedLanguageDetection.languages,
primaryLanguageCode: normalizedLanguageDetection.primaryLanguageCode,
ambiguityReasons: normalizedLanguageDetection.ambiguityReasons,
};
};
const extractSurveySemantics = async (
openai: OpenAI,
extractedText: string,
languageCodes: string[],
defaultLanguageCode: string
): Promise<TLLMSurveyExtraction> => {
const response = await openai.responses.parse({
model: "gpt-4.1",
input: [
{ role: "system", content: SEMANTIC_EXTRACTION_SYSTEM_PROMPT },
{
role: "user",
content: [
`Allowed language codes: ${languageCodes.join(", ")}`,
`Default language code: ${defaultLanguageCode}`,
"Convert this survey document into structured multilingual survey data:",
extractedText,
].join("\n\n"),
},
],
text: {
format: zodTextFormat(ZLLMSurveyExtraction, "survey_docx_semantic_extraction"),
},
});
const parsedOutput = response.output_parsed;
if (!parsedOutput) {
throw new Error("The model did not return structured survey extraction data");
}
return parsedOutput;
};
export const convertDocxToSurveyPayload = async (
fileBuffer: Buffer,
fileName: string,
context?: TDocxConversionContext
): Promise<TDocxConversionResult> => {
logger.info(
{
importRunId: context?.importRunId,
environmentId: context?.environmentId,
userId: context?.userId,
fileName,
fileSizeBytes: fileBuffer.length,
},
"Survey import: starting DOCX conversion"
);
try {
if (!fileName.toLowerCase().endsWith(".docx")) {
throw new Error("Only .docx files are supported. Please convert your .doc file to .docx first.");
}
const extractedText = await extractRawTextFromDocx(fileBuffer);
logger.info(
{
importRunId: context?.importRunId,
extractedChars: extractedText.length,
},
"Survey import: extracted DOCX text"
);
const openai = getOpenAIClient();
const languageDetection = await detectSurveyLanguages(openai, extractedText);
const languageCodes = languageDetection.languages.map((entry) => entry.code);
logger.info(
{
importRunId: context?.importRunId,
detectedLanguageCount: languageDetection.languages.length,
detectedLanguages: languageDetection.languages.map(
(entry) => `${entry.code}:${entry.confidence.toFixed(2)}`
),
primaryLanguageCode: languageDetection.primaryLanguageCode,
},
"Survey import: language detection completed"
);
const parsedOutput = await extractSurveySemantics(
openai,
extractedText,
languageCodes,
languageDetection.primaryLanguageCode
);
logger.info(
{
importRunId: context?.importRunId,
surveyTitle: parsedOutput.surveyTitle,
questionCount: parsedOutput.questions.length,
notesCount: parsedOutput.notes.length,
},
"Survey import: LLM returned structured extraction"
);
const surveyData = mapToExportData(parsedOutput, languageCodes, languageDetection.primaryLanguageCode);
const result = {
surveyData: {
version: SURVEY_EXPORT_VERSION,
exportDate: new Date().toISOString(),
data: surveyData,
},
notes: [
...parsedOutput.notes,
`Detected languages: ${languageDetection.languages
.map((entry) => `${entry.code} (${Math.round(entry.confidence * 100)}%)`)
.join(", ")}`,
],
detectedLanguages: languageDetection.languages,
};
logger.info(
{
importRunId: context?.importRunId,
outputQuestionCount: parsedOutput.questions.length,
outputBlockCount: result.surveyData.data.blocks.length,
},
"Survey import: DOCX conversion completed"
);
return result;
} catch (error) {
logger.error(
{
importRunId: context?.importRunId,
environmentId: context?.environmentId,
userId: context?.userId,
fileName,
},
"Survey import: DOCX conversion failed"
);
logger.error(error, "Survey import: DOCX conversion error details");
throw error;
}
};

View File

@@ -0,0 +1,41 @@
import { getProject } from "@/lib/project/service";
import { type TExportedLanguage } from "../export-survey";
export interface TMappedLanguage {
languageId: string;
enabled: boolean;
default: boolean;
}
export const mapLanguages = async (
exportedLanguages: TExportedLanguage[],
projectId: string
): Promise<{ mapped: TMappedLanguage[]; skipped: string[] }> => {
if (!exportedLanguages || exportedLanguages.length === 0) {
return { mapped: [], skipped: [] };
}
const project = await getProject(projectId);
if (!project) {
return { mapped: [], skipped: ["Project not found"] };
}
const mappedLanguages: TMappedLanguage[] = [];
const skipped: string[] = [];
for (const exportedLang of exportedLanguages) {
const projectLanguage = project.languages.find((l) => l.code === exportedLang.code);
if (!projectLanguage) {
skipped.push(`Language ${exportedLang.code} not found in project`);
continue;
}
mappedLanguages.push({
languageId: projectLanguage.id,
enabled: exportedLang.enabled,
default: exportedLang.default,
});
}
return { mapped: mappedLanguages, skipped };
};

View File

@@ -0,0 +1,55 @@
import { TActionClassInput } from "@formbricks/types/action-classes";
import { createActionClass } from "@/modules/survey/editor/lib/action-class";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { type TExportedTrigger } from "../export-survey";
export interface TMappedTrigger {
actionClass: { id: string };
}
export const mapTriggers = async (
importedTriggers: TExportedTrigger[],
environmentId: string
): Promise<{ mapped: TMappedTrigger[]; skipped: string[] }> => {
if (!importedTriggers || importedTriggers.length === 0) {
return { mapped: [], skipped: [] };
}
const existingActionClasses = await getActionClasses(environmentId);
const mappedTriggers: TMappedTrigger[] = [];
const skipped: string[] = [];
for (const trigger of importedTriggers) {
const ac = trigger.actionClass;
let existing = existingActionClasses.find((e) => e.key === ac.key && e.type === ac.type);
if (!existing) {
try {
const actionClassInput: TActionClassInput = {
environmentId,
name: `${ac.name} (imported)`,
description: ac.description ?? null,
type: ac.type,
key: ac.key,
noCodeConfig: ac.noCodeConfig,
};
existing = await createActionClass(environmentId, actionClassInput);
} catch (error) {
existing = await getActionClasses(environmentId).then((classes) =>
classes.find((e) => e.key === ac.key && e.type === ac.type)
);
}
}
if (existing) {
mappedTriggers.push({
actionClass: { id: existing.id },
});
} else {
skipped.push(`Could not find or create action class: ${ac.name} (${ac.key ?? "no key"})`);
}
}
return { mapped: mappedTriggers, skipped };
};

View File

@@ -0,0 +1,73 @@
import { describe, expect, test, vi } from "vitest";
import { resolveMissingProjectLanguages } from "./missing-language-resolution";
describe("resolveMissingProjectLanguages", () => {
test("returns no-op when all imported languages already exist", async () => {
const result = await resolveMissingProjectLanguages({
importedLanguageCodes: ["en", "de"],
existingLanguageCodes: ["en", "de", "fr"],
hasManagePermission: true,
createLanguage: vi.fn(),
refreshExistingLanguageCodes: vi.fn(async () => ["en", "de", "fr"]),
getLanguageNames: (codes) => codes,
});
expect(result).toEqual({ createdLanguageCodes: [] });
});
test("returns permission guidance when manage permission is missing", async () => {
const result = await resolveMissingProjectLanguages({
importedLanguageCodes: ["en", "de"],
existingLanguageCodes: ["en"],
hasManagePermission: false,
createLanguage: vi.fn(),
refreshExistingLanguageCodes: vi.fn(async () => ["en"]),
getLanguageNames: (codes) => codes.map((code) => `language-${code}`),
});
expect(result.createdLanguageCodes).toEqual([]);
expect(result.errorMessage).toContain("language-de");
expect(result.errorMessage).toContain("manage permissions");
});
test("creates missing languages when permission is available", async () => {
const createLanguage = vi.fn(async () => undefined);
const refreshExistingLanguageCodes = vi.fn(async () => ["en", "de", "fr"]);
const result = await resolveMissingProjectLanguages({
importedLanguageCodes: ["en", "de", "fr"],
existingLanguageCodes: ["en"],
hasManagePermission: true,
createLanguage,
refreshExistingLanguageCodes,
getLanguageNames: (codes) => codes,
});
expect(createLanguage).toHaveBeenCalledTimes(2);
expect(createLanguage).toHaveBeenNthCalledWith(1, "de");
expect(createLanguage).toHaveBeenNthCalledWith(2, "fr");
expect(refreshExistingLanguageCodes).toHaveBeenCalledTimes(1);
expect(result).toEqual({ createdLanguageCodes: ["de", "fr"] });
});
test("returns actionable error when some languages remain unresolved after creation", async () => {
const createLanguage = vi.fn(async (code: string) => {
if (code === "fr") {
throw new Error("create failed");
}
});
const result = await resolveMissingProjectLanguages({
importedLanguageCodes: ["en", "de", "fr"],
existingLanguageCodes: ["en"],
hasManagePermission: true,
createLanguage,
refreshExistingLanguageCodes: vi.fn(async () => ["en", "de"]),
getLanguageNames: (codes) => codes.map((code) => `language-${code}`),
});
expect(result.createdLanguageCodes).toEqual(["de"]);
expect(result.errorMessage).toContain("language-fr");
expect(result.errorMessage).toContain("Please add them in Project Configuration");
});
});

View File

@@ -0,0 +1,68 @@
interface TResolveMissingProjectLanguagesInput {
importedLanguageCodes: string[];
existingLanguageCodes: string[];
hasManagePermission: boolean;
createLanguage: (code: string) => Promise<void>;
refreshExistingLanguageCodes: () => Promise<string[]>;
getLanguageNames: (languageCodes: string[]) => string[];
}
interface TResolveMissingProjectLanguagesResult {
createdLanguageCodes: string[];
errorMessage?: string;
}
const getMissingLanguageCodes = (importedLanguageCodes: string[], existingLanguageCodes: string[]) => {
const uniqueImported = [...new Set(importedLanguageCodes)];
return uniqueImported.filter((code) => !existingLanguageCodes.includes(code));
};
export const resolveMissingProjectLanguages = async ({
importedLanguageCodes,
existingLanguageCodes,
hasManagePermission,
createLanguage,
refreshExistingLanguageCodes,
getLanguageNames,
}: TResolveMissingProjectLanguagesInput): Promise<TResolveMissingProjectLanguagesResult> => {
const missingLanguageCodes = getMissingLanguageCodes(importedLanguageCodes, existingLanguageCodes);
if (missingLanguageCodes.length === 0) {
return { createdLanguageCodes: [] };
}
if (!hasManagePermission) {
const missingLanguageNames = getLanguageNames(missingLanguageCodes);
return {
createdLanguageCodes: [],
errorMessage: `This import contains languages not configured in your project: ${missingLanguageNames.join(
", "
)}. You need workspace manage permissions to auto-create missing languages. Please add them in Project Configuration or ask a project manager.`,
};
}
const createdLanguageCodes: string[] = [];
for (const code of missingLanguageCodes) {
try {
await createLanguage(code);
createdLanguageCodes.push(code);
} catch {}
}
const refreshedExistingLanguageCodes = await refreshExistingLanguageCodes();
const unresolvedLanguageCodes = getMissingLanguageCodes(
importedLanguageCodes,
refreshedExistingLanguageCodes
);
if (unresolvedLanguageCodes.length > 0) {
const unresolvedLanguageNames = getLanguageNames(unresolvedLanguageCodes);
return {
createdLanguageCodes,
errorMessage: `Import could not auto-create these project languages: ${unresolvedLanguageNames.join(
", "
)}. Please add them in Project Configuration and try again.`,
};
}
return { createdLanguageCodes };
};

View File

@@ -0,0 +1,60 @@
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { addMultiLanguageLabels } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { type TMappedLanguage } from "./map-languages";
import { type TImportCapabilities } from "./permissions";
export const stripUnavailableFeatures = (
survey: TSurveyCreateInput,
capabilities: TImportCapabilities
): TSurveyCreateInput => {
const cloned = structuredClone(survey);
if (!capabilities.hasMultiLanguage) {
cloned.languages = [];
}
if (!capabilities.hasFollowUps) {
cloned.followUps = [];
}
if (!capabilities.hasRecaptcha) {
cloned.recaptcha = null;
}
delete cloned.segment;
return cloned;
};
export interface TSurveyLanguageConnection {
create: { languageId: string; enabled: boolean; default: boolean }[];
}
export const normalizeLanguagesForCreation = (
languages: TMappedLanguage[]
): TSurveyLanguageConnection | undefined => {
if (!languages || languages.length === 0) {
return undefined;
}
return {
create: languages.map((lang) => ({
languageId: lang.languageId,
enabled: lang.enabled,
default: lang.default,
})),
};
};
export const addLanguageLabels = (
survey: TSurveyCreateInput,
languageCodes: string[]
): TSurveyCreateInput => {
if (!languageCodes || languageCodes.length === 0) {
return survey;
}
const cloned = structuredClone(survey);
return addMultiLanguageLabels(cloned, languageCodes);
};

View File

@@ -0,0 +1,116 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TSurveyCreateInput, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
import {
SURVEY_EXPORT_VERSION,
type TExportedLanguage,
type TExportedTrigger,
ZExportedLanguage,
ZExportedTrigger,
ZSurveyExportPayload,
} from "../export-survey";
export interface TParsedPayload {
surveyInput: TSurveyCreateInput;
exportedLanguages: TExportedLanguage[];
triggers: TExportedTrigger[];
}
export interface TParseError {
error: string;
details?: string[];
}
const getZodIssues = (error: z.ZodError): z.ZodIssue[] => {
const maybeIssues = (error as unknown as { issues?: z.ZodIssue[] }).issues;
if (Array.isArray(maybeIssues)) {
return maybeIssues;
}
const maybeErrors = (error as unknown as { errors?: z.ZodIssue[] }).errors;
if (Array.isArray(maybeErrors)) {
return maybeErrors;
}
return [];
};
export const parseSurveyPayload = (surveyData: unknown): TParsedPayload | TParseError => {
if (typeof surveyData !== "object" || surveyData === null) {
return { error: "Invalid survey data: expected an object" };
}
let actualSurveyData: Record<string, unknown>;
// Check if this is the new versioned format (with version, exportDate, and data wrapper)
const versionedFormatCheck = ZSurveyExportPayload.safeParse(surveyData);
if (versionedFormatCheck.success) {
// New format: extract the data from the wrapper
const { version, data } = versionedFormatCheck.data;
// Validate version (for future compatibility)
if (version !== SURVEY_EXPORT_VERSION) {
logger.warn(
`Import: Survey export version ${version} differs from current version ${SURVEY_EXPORT_VERSION}`
);
}
actualSurveyData = data as Record<string, unknown>;
} else {
// Legacy format or pre-versioning format: use data as-is
actualSurveyData = surveyData as Record<string, unknown>;
}
const surveyDataCopy = { ...actualSurveyData } as Record<string, unknown>;
// Validate and extract languages
const languagesResult = z.array(ZExportedLanguage).safeParse(surveyDataCopy.languages ?? []);
if (!languagesResult.success) {
const issues = getZodIssues(languagesResult.error);
return {
error: "Invalid languages format",
details: issues.map((e) => {
const path = e.path.length > 0 ? `languages.${e.path.join(".")}` : "languages";
return `Field "${path}": ${e.message}`;
}),
};
}
const exportedLanguages = languagesResult.data;
// Validate and extract triggers
const triggersResult = z.array(ZExportedTrigger).safeParse(surveyDataCopy.triggers ?? []);
if (!triggersResult.success) {
const issues = getZodIssues(triggersResult.error);
return {
error: "Invalid triggers format",
details: issues.map((e) => {
const path = e.path.length > 0 ? `triggers.${e.path.join(".")}` : "triggers";
return `Field "${path}": ${e.message}`;
}),
};
}
const triggers = triggersResult.data;
// Remove these from the copy before validating against ZSurveyCreateInput
delete surveyDataCopy.languages;
delete surveyDataCopy.triggers;
// Validate the main survey structure
const surveyResult = ZSurveyCreateInput.safeParse(surveyDataCopy);
if (!surveyResult.success) {
const issues = getZodIssues(surveyResult.error);
return {
error: "Invalid survey format",
details: issues.map((e) => {
const path = e.path.length > 0 ? e.path.join(".") : "root";
return `Field "${path}": ${e.message}`;
}),
};
}
return {
surveyInput: surveyResult.data,
exportedLanguages,
triggers,
};
};

View File

@@ -0,0 +1,29 @@
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
export interface TImportCapabilities {
hasMultiLanguage: boolean;
hasFollowUps: boolean;
hasRecaptcha: boolean;
}
export const resolveImportCapabilities = async (organizationId: string): Promise<TImportCapabilities> => {
const hasMultiLanguage = true;
let hasFollowUps = false;
try {
const organizationBilling = await getOrganizationBilling(organizationId);
if (organizationBilling) {
hasFollowUps = await getSurveyFollowUpsPermission(organizationBilling.plan);
}
} catch (e) {}
let hasRecaptcha = false;
try {
await checkSpamProtectionPermission(organizationId);
hasRecaptcha = true;
} catch (e) {}
return { hasMultiLanguage, hasFollowUps, hasRecaptcha };
};

View File

@@ -0,0 +1,34 @@
import { createId } from "@paralleldrive/cuid2";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { createSurvey } from "@/modules/survey/components/template-list/lib/survey";
import { type TMappedTrigger } from "./map-triggers";
import { type TSurveyLanguageConnection } from "./normalize-survey";
export const persistSurvey = async (
environmentId: string,
survey: TSurveyCreateInput,
newName: string,
createdBy: string,
mappedTriggers: TMappedTrigger[],
mappedLanguages?: TSurveyLanguageConnection
): Promise<{ surveyId: string }> => {
const followUpsWithNewIds = survey.followUps?.map((f) => ({
...f,
id: createId(),
surveyId: createId(),
}));
const surveyToCreate = {
...survey,
name: newName,
status: "draft" as const,
triggers: mappedTriggers as any, // Type system expects full ActionClass, but createSurvey only uses the id
followUps: followUpsWithNewIds,
createdBy,
...(mappedLanguages && { languages: mappedLanguages as any }), // Prisma nested create format
} as TSurveyCreateInput;
const newSurvey = await createSurvey(environmentId, surveyToCreate);
return { surveyId: newSurvey.id };
};

View File

@@ -0,0 +1,175 @@
import { createId } from "@paralleldrive/cuid2";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ValidationError } from "@formbricks/types/errors";
import { type TSurveyExportPayload } from "../export-survey";
import { createRemoteSurveyFromPayload } from "./remote-survey-create";
vi.mock("@/lib/env", () => ({
env: {
SURVEY_IMPORT_TARGET_HOST: "https://remote.example.com",
SURVEY_IMPORT_TARGET_ENVIRONMENT_ID: "env_remote",
SURVEY_IMPORT_TARGET_API_KEY: "api_key_remote",
},
}));
const buildPayload = (
languages: Array<{ code: string; enabled: boolean; default: boolean }>
): TSurveyExportPayload => ({
version: "1.0.0",
exportDate: new Date().toISOString(),
data: {
name: "Imported Survey",
type: "link",
questions: [],
blocks: [
{
id: createId(),
name: "Question 1",
elements: [
{
id: "q_1",
type: "openText",
inputType: "text",
charLimit: { enabled: false },
required: true,
headline: { default: "How are you?" },
},
],
},
],
endings: [
{
id: createId(),
type: "endScreen",
headline: { default: "Thank you!" },
},
],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
timeToFinish: true,
showResponseCount: false,
},
triggers: [],
languages,
followUps: [],
},
});
describe("createRemoteSurveyFromPayload", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
test("creates remote survey for multilingual payload and forwards language codes", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ data: { id: "survey_remote_multilingual" } }),
} as Response);
await createRemoteSurveyFromPayload(
buildPayload([
{ code: "en", enabled: true, default: true },
{ code: "de", enabled: true, default: false },
]),
"Imported Survey"
);
expect(fetchSpy).toHaveBeenCalledTimes(1);
const request = fetchSpy.mock.calls[0];
const requestBody = JSON.parse((request?.[1]?.body as string) ?? "{}") as {
languages?: Array<{ code: string; enabled: boolean; default: boolean }>;
};
expect(requestBody.languages).toEqual([
{ code: "en", enabled: true, default: true },
{ code: "de", enabled: true, default: false },
]);
});
test("creates remote survey for mono-language payload", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ data: { id: "survey_remote_1" } }),
} as Response);
const result = await createRemoteSurveyFromPayload(
buildPayload([{ code: "en", enabled: true, default: true }]),
"Imported Survey"
);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(result).toEqual({
surveyId: "survey_remote_1",
surveyUrl: "https://remote.example.com/environments/env_remote/surveys/survey_remote_1/edit",
});
const request = fetchSpy.mock.calls[0];
const requestBody = JSON.parse((request?.[1]?.body as string) ?? "{}") as {
languages?: unknown;
};
expect(requestBody.languages).toBeUndefined();
});
test("surfaces remote validation details in thrown error", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 400,
json: async () => ({
code: "bad_request",
message: "Fields are missing or incorrectly formatted",
details: {
"languages.1.code": "Invalid input",
"languages.1.default": "Expected boolean",
},
}),
} as Response);
await expect(
createRemoteSurveyFromPayload(
buildPayload([
{ code: "en", enabled: true, default: true },
{ code: "de", enabled: true, default: false },
]),
"Imported Survey"
)
).rejects.toBeInstanceOf(ValidationError);
await expect(
createRemoteSurveyFromPayload(
buildPayload([
{ code: "en", enabled: true, default: true },
{ code: "de", enabled: true, default: false },
]),
"Imported Survey"
)
).rejects.toThrow(
"Fields are missing or incorrectly formatted\nlanguages.1.code: Invalid input\nlanguages.1.default: Expected boolean"
);
});
test("surfaces compatibility hint for older remote language schema", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 400,
json: async () => ({
code: "bad_request",
message: "Fields are missing or incorrectly formatted",
details: {
"languages.0.language": "Invalid input: expected object, received undefined",
"languages.1.language": "Invalid input: expected object, received undefined",
},
}),
} as Response);
await expect(
createRemoteSurveyFromPayload(
buildPayload([
{ code: "en", enabled: true, default: true },
{ code: "de", enabled: true, default: false },
]),
"Imported Survey"
)
).rejects.toThrow(
"Remote target is using an older survey management API that cannot map imported language codes yet."
);
});
});

View File

@@ -0,0 +1,181 @@
import { logger } from "@formbricks/logger";
import { ValidationError } from "@formbricks/types/errors";
import { env } from "@/lib/env";
import { type TSurveyExportPayload } from "../export-survey";
import { addLanguageLabels } from "./normalize-survey";
import { parseSurveyPayload } from "./parse-payload";
interface TRemoteCreateResult {
surveyId: string;
surveyUrl: string;
}
interface TRemoteCreateContext {
importRunId?: string;
requestedByUserId?: string;
}
const normalizeHost = (host: string): string => host.replace(/\/+$/, "");
const getRemoteConfig = () => {
if (!env.SURVEY_IMPORT_TARGET_HOST) {
throw new Error("SURVEY_IMPORT_TARGET_HOST is not set");
}
if (!env.SURVEY_IMPORT_TARGET_ENVIRONMENT_ID) {
throw new Error("SURVEY_IMPORT_TARGET_ENVIRONMENT_ID is not set");
}
if (!env.SURVEY_IMPORT_TARGET_API_KEY) {
throw new Error("SURVEY_IMPORT_TARGET_API_KEY is not set");
}
return {
host: normalizeHost(env.SURVEY_IMPORT_TARGET_HOST),
environmentId: env.SURVEY_IMPORT_TARGET_ENVIRONMENT_ID,
apiKey: env.SURVEY_IMPORT_TARGET_API_KEY,
};
};
const formatRemoteErrorDetails = (details: unknown): string[] => {
if (!details || typeof details !== "object" || Array.isArray(details)) {
return [];
}
return Object.entries(details as Record<string, unknown>).flatMap(([field, value]) => {
if (Array.isArray(value)) {
if (value.length === 0) {
return [];
}
return `${field}: ${value.map(String).join(", ")}`;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return `${field}: ${String(value)}`;
}
return [];
});
};
const isRemoteLanguageCompatibilityError = (details: string[]): boolean =>
details.some(
(detail) =>
detail.includes("languages.") &&
detail.includes(".language") &&
detail.includes("expected object") &&
detail.includes("received undefined")
);
const extractRemoteErrorMessage = async (response: Response): Promise<string> => {
try {
const body = await response.json();
if (typeof body?.message === "string" && body.message.length > 0) {
const details = formatRemoteErrorDetails(body?.details);
const compatibilityHint = isRemoteLanguageCompatibilityError(details)
? [
"",
"Remote target is using an older survey management API that cannot map imported language codes yet.",
"Deploy the target instance with the multilingual remote import compatibility update and try again.",
].join("\n")
: "";
return details.length > 0
? `${body.message}\n${details.join("\n")}${compatibilityHint}`
: `${body.message}${compatibilityHint}`;
}
if (typeof body?.error?.message === "string" && body.error.message.length > 0) {
return body.error.message;
}
} catch {}
return `Remote API request failed with status ${response.status}`;
};
export const createRemoteSurveyFromPayload = async (
surveyData: TSurveyExportPayload,
newName: string,
context?: TRemoteCreateContext
): Promise<TRemoteCreateResult> => {
logger.info(
{
importRunId: context?.importRunId,
requestedByUserId: context?.requestedByUserId,
newName,
},
"Survey import: starting remote survey creation"
);
const parsed = parseSurveyPayload(surveyData);
if ("error" in parsed) {
const details = parsed.details?.length ? `: ${parsed.details.join(", ")}` : "";
logger.warn(
{
importRunId: context?.importRunId,
parseError: parsed.error,
details: parsed.details,
},
"Survey import: remote payload validation failed"
);
throw new ValidationError(`${parsed.error}${details}`);
}
const config = getRemoteConfig();
const hasMultipleLanguages = parsed.exportedLanguages.some((language) => !language.default);
const nonDefaultLanguageCodes = parsed.exportedLanguages
.filter((language) => !language.default)
.map((language) => language.code);
const surveyInputWithLanguageLabels = addLanguageLabels(parsed.surveyInput, nonDefaultLanguageCodes);
const requestPayload: Record<string, unknown> = {
...surveyInputWithLanguageLabels,
environmentId: config.environmentId,
name: newName,
};
if (hasMultipleLanguages) {
requestPayload.languages = parsed.exportedLanguages;
} else {
delete requestPayload.languages;
}
const response = await fetch(`${config.host}/api/v1/management/surveys`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey,
},
body: JSON.stringify(requestPayload),
});
if (!response.ok) {
logger.warn(
{
importRunId: context?.importRunId,
targetHost: config.host,
targetEnvironmentId: config.environmentId,
status: response.status,
},
"Survey import: remote API returned non-OK response"
);
throw new ValidationError(await extractRemoteErrorMessage(response));
}
const responseBody = (await response.json()) as { data?: { id?: string } };
const surveyId = responseBody?.data?.id;
if (!surveyId) {
throw new Error("Remote API did not return a survey id");
}
const result = {
surveyId,
surveyUrl: `${config.host}/environments/${config.environmentId}/surveys/${surveyId}/edit`,
};
logger.info(
{
importRunId: context?.importRunId,
targetHost: config.host,
targetEnvironmentId: config.environmentId,
surveyId: result.surveyId,
},
"Survey import: remote survey created successfully"
);
return result;
};

View File

@@ -9,6 +9,7 @@ import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
import { ImportSurveyButton } from "@/modules/survey/list/components/import-survey-button";
import { SurveysList } from "@/modules/survey/list/components/survey-list";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { TemplateContainerWithPreview } from "@/modules/survey/templates/components/template-container";
@@ -47,14 +48,17 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
const currentProjectChannel = project.config.channel ?? null;
const locale = (await getUserLocale(session.user.id)) ?? DEFAULT_LOCALE;
const CreateSurveyButton = () => {
const SurveyListCTA = () => {
return (
<Button size="sm" asChild>
<Link href={`/environments/${environment.id}/surveys/templates`}>
{t("environments.surveys.new_survey")}
<PlusIcon />
</Link>
</Button>
<div className="flex gap-2">
<ImportSurveyButton environmentId={environment.id} />
<Button size="sm" asChild>
<Link href={`/environments/${environment.id}/surveys/templates`}>
{t("environments.surveys.new_survey")}
<PlusIcon />
</Link>
</Button>
</div>
);
};
@@ -79,7 +83,7 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
if (surveyCount > 0) {
content = (
<>
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : <CreateSurveyButton />} />
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : <SurveyListCTA />} />
<SurveysList
environmentId={environment.id}
isReadOnly={isReadOnly}

View File

@@ -95,12 +95,14 @@
"lexical": "0.41.0",
"lodash": "4.17.23",
"lucide-react": "0.577.0",
"mammoth": "1.12.0",
"markdown-it": "14.1.1",
"next": "16.1.7",
"next-auth": "4.24.13",
"next-safe-action": "8.1.8",
"node-fetch": "3.3.2",
"nodemailer": "8.0.2",
"openai": "6.33.0",
"otplib": "12.0.1",
"papaparse": "5.5.3",
"posthog-js": "1.360.0",

116
pnpm-lock.yaml generated
View File

@@ -342,6 +342,9 @@ importers:
lucide-react:
specifier: 0.577.0
version: 0.577.0(react@19.2.4)
mammoth:
specifier: 1.12.0
version: 1.12.0
markdown-it:
specifier: 14.1.1
version: 14.1.1
@@ -360,6 +363,9 @@ importers:
nodemailer:
specifier: 8.0.2
version: 8.0.2
openai:
specifier: 6.33.0
version: 6.33.0(ws@8.18.3)(zod@4.3.6)
otplib:
specifier: 12.0.1
version: 12.0.1
@@ -5862,6 +5868,7 @@ packages:
'@xmldom/xmldom@0.8.11':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@xmldom/xmldom@0.9.8':
resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==}
@@ -6199,6 +6206,9 @@ packages:
bl@6.1.6:
resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==}
bluebird@3.4.7:
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
@@ -6730,6 +6740,9 @@ packages:
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dingbat-to-unicode@1.0.1:
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -6795,6 +6808,9 @@ packages:
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
engines: {node: '>=12'}
duck@0.1.12:
resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -7717,6 +7733,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -8106,6 +8125,9 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
@@ -8148,6 +8170,9 @@ packages:
resolution: {integrity: sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==}
engines: {node: '>=8.0.0'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lightningcss-android-arm64@1.31.1:
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
engines: {node: '>= 12.0.0'}
@@ -8320,6 +8345,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lop@0.4.2:
resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -8370,6 +8398,11 @@ packages:
resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==}
engines: {node: '>= 10'}
mammoth@1.12.0:
resolution: {integrity: sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==}
engines: {node: '>=12.0.0'}
hasBin: true
markdown-it@14.1.1:
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
hasBin: true
@@ -8811,12 +8844,27 @@ packages:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
openai@6.33.0:
resolution: {integrity: sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
openid-client@5.7.1:
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
openid-client@6.5.0:
resolution: {integrity: sha512-fAfYaTnOYE2kQCqEJGX9KDObW2aw7IQy4jWpU/+3D3WoCFLbix5Hg6qIPQ6Js9r7f8jDUmsnnguRNCSw4wU/IQ==}
option@0.2.4:
resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -8862,6 +8910,9 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
papaparse@5.5.3:
resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==}
@@ -9768,6 +9819,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
sha.js@2.4.12:
resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==}
engines: {node: '>= 0.10'}
@@ -10511,6 +10565,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
underscore@1.13.8:
resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -10890,6 +10947,10 @@ packages:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
xmlbuilder@10.1.1:
resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==}
engines: {node: '>=4.0'}
xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
@@ -17740,6 +17801,8 @@ snapshots:
inherits: 2.0.4
readable-stream: 4.7.0
bluebird@3.4.7: {}
boolbase@1.0.0: {}
boring-avatars@2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
@@ -18265,6 +18328,8 @@ snapshots:
dijkstrajs@1.0.3: {}
dingbat-to-unicode@1.0.1: {}
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
@@ -18330,6 +18395,10 @@ snapshots:
dotenv@17.3.1: {}
duck@0.1.12:
dependencies:
underscore: 1.13.8
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -19543,6 +19612,8 @@ snapshots:
ignore@7.0.5: {}
immediate@3.0.6: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -19936,6 +20007,13 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
@@ -19976,6 +20054,10 @@ snapshots:
libheif-js@1.19.8: {}
lie@3.3.0:
dependencies:
immediate: 3.0.6
lightningcss-android-arm64@1.31.1:
optional: true
@@ -20124,6 +20206,12 @@ snapshots:
dependencies:
js-tokens: 4.0.0
lop@0.4.2:
dependencies:
duck: 0.1.12
option: 0.2.4
underscore: 1.13.8
loupe@3.2.1: {}
lru-cache@10.4.3: {}
@@ -20193,6 +20281,19 @@ snapshots:
- supports-color
optional: true
mammoth@1.12.0:
dependencies:
'@xmldom/xmldom': 0.8.11
argparse: 1.0.10
base64-js: 1.5.1
bluebird: 3.4.7
dingbat-to-unicode: 1.0.1
jszip: 3.10.1
lop: 0.4.2
path-is-absolute: 1.0.1
underscore: 1.13.8
xmlbuilder: 10.1.1
markdown-it@14.1.1:
dependencies:
argparse: 2.0.1
@@ -20659,6 +20760,11 @@ snapshots:
powershell-utils: 0.1.0
wsl-utils: 0.3.1
openai@6.33.0(ws@8.18.3)(zod@4.3.6):
optionalDependencies:
ws: 8.18.3
zod: 4.3.6
openid-client@5.7.1:
dependencies:
jose: 4.15.9
@@ -20671,6 +20777,8 @@ snapshots:
jose: 6.0.11
oauth4webapi: 3.8.3
option@0.2.4: {}
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -20740,6 +20848,8 @@ snapshots:
package-json-from-dist@1.0.1: {}
pako@1.0.11: {}
papaparse@5.5.3: {}
parent-module@1.0.1:
@@ -21717,6 +21827,8 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
setimmediate@1.0.5: {}
sha.js@2.4.12:
dependencies:
inherits: 2.0.4
@@ -22534,6 +22646,8 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
underscore@1.13.8: {}
undici-types@6.21.0: {}
undici-types@7.18.2: {}
@@ -22977,6 +23091,8 @@ snapshots:
sax: 1.4.3
xmlbuilder: 11.0.1
xmlbuilder@10.1.1: {}
xmlbuilder@11.0.1: {}
xmlbuilder@15.1.1: {}

View File

@@ -198,6 +198,7 @@
"OIDC_DISPLAY_NAME",
"OIDC_ISSUER",
"OIDC_SIGNING_ALGORITHM",
"OPENAI_API_KEY",
"PASSWORD_RESET_DISABLED",
"PLAYWRIGHT_CI",
"PRIVACY_URL",
@@ -227,6 +228,10 @@
"STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET",
"STRIPE_PUBLISHABLE_KEY",
"SURVEY_IMPORT_DESTINATION",
"SURVEY_IMPORT_TARGET_API_KEY",
"SURVEY_IMPORT_TARGET_ENVIRONMENT_ID",
"SURVEY_IMPORT_TARGET_HOST",
"SURVEYS_PACKAGE_MODE",
"SURVEYS_PACKAGE_BUILD",
"PUBLIC_URL",