diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx index c401d76269..b78150687c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx @@ -9,6 +9,7 @@ import { PanelLeftCloseIcon, PanelLeftOpenIcon, RocketIcon, + ShapesIcon, UserCircleIcon, UserIcon, } from "lucide-react"; @@ -99,7 +100,7 @@ export const MainNavigation = ({ const mainNavigation = useMemo( () => [ { - name: t("common.surveys"), + name: t("common.ask"), href: `/environments/${environment.id}/surveys`, icon: MessageCircle, isActive: pathname?.includes("/surveys"), @@ -107,7 +108,7 @@ export const MainNavigation = ({ }, { href: `/environments/${environment.id}/contacts`, - name: t("common.contacts"), + name: t("common.distribute"), icon: UserIcon, isActive: pathname?.includes("/contacts") || @@ -115,7 +116,13 @@ export const MainNavigation = ({ pathname?.includes("/attributes"), }, { - name: t("common.configuration"), + name: t("common.unify"), + href: `/environments/${environment.id}/workspace/unify`, + icon: ShapesIcon, + isActive: pathname?.includes("/unify") && !pathname?.includes("/analyze"), + }, + { + name: t("common.configure"), href: `/environments/${environment.id}/workspace/general`, icon: Cog, isActive: pathname?.includes("/project"), @@ -188,7 +195,7 @@ export const MainNavigation = ({ size="icon" onClick={toggleSidebar} className={cn( - "rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none" + "rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent" )}> {isCollapsed ? ( diff --git a/apps/web/app/(app)/environments/[environmentId]/components/project-breadcrumb.tsx b/apps/web/app/(app)/environments/[environmentId]/components/project-breadcrumb.tsx index ccb371a489..e933f62638 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/project-breadcrumb.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/project-breadcrumb.tsx @@ -133,6 +133,11 @@ export const ProjectBreadcrumb = ({ label: t("common.tags"), href: `/environments/${currentEnvironmentId}/workspace/tags`, }, + { + id: "unify", + label: t("common.unify"), + href: `/environments/${currentEnvironmentId}/workspace/unify`, + }, ]; if (!currentProject) { diff --git a/apps/web/app/(app)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/layout.tsx index 8196a948d7..8243948a20 100644 --- a/apps/web/app/(app)/environments/[environmentId]/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/layout.tsx @@ -11,6 +11,12 @@ const EnvLayout = async (props: { children: React.ReactNode; }) => { const params = await props.params; + const { environmentId } = params; + + if (environmentId === "undefined") { + return redirect("/"); + } + const { children } = props; // Check session first (required for userId) diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/AddIntegrationModal.tsx index 0ee9b61ac8..0b16be342f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/AddIntegrationModal.tsx @@ -280,7 +280,7 @@ export const AddIntegrationModal = ({
-
+
{surveyElements.map((question) => (
diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/components/UnifyConfigNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/components/UnifyConfigNavigation.tsx new file mode 100644 index 0000000000..5d644e53c3 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/components/UnifyConfigNavigation.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; + +interface UnifyConfigNavigationProps { + environmentId: string; + activeId?: string; + loading?: boolean; +} + +export const UnifyConfigNavigation = ({ + environmentId, + activeId: activeIdProp, + loading, +}: UnifyConfigNavigationProps) => { + const { t } = useTranslation(); + const baseHref = `/environments/${environmentId}/workspace/unify`; + + const activeId = activeIdProp ?? "sources"; + + const navigation = [{ id: "sources", label: t("environments.unify.sources"), href: `${baseHref}/sources` }]; + + return ; +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/page.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/page.tsx new file mode 100644 index 0000000000..27c5e18b32 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default async function UnifyPage(props: { params: Promise<{ environmentId: string }> }) { + const params = await props.params; + redirect(`/environments/${params.environmentId}/workspace/unify/sources`); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/actions.ts b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/actions.ts new file mode 100644 index 0000000000..90310a0ef6 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/actions.ts @@ -0,0 +1,38 @@ +"use server"; + +import { z } from "zod"; +import { ZId } from "@formbricks/types/common"; +import { getSurveys } from "@/lib/survey/service"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper"; +import { transformToUnifySurvey } from "./lib"; +import { TUnifySurvey } from "./types"; + +const ZGetSurveysForUnifyAction = z.object({ + environmentId: ZId, +}); + +export const getSurveysForUnifyAction = authenticatedActionClient + .schema(ZGetSurveysForUnifyAction) + .action(async ({ ctx, parsedInput }): Promise => { + const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId); + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager", "member"], + }, + { + type: "projectTeam", + minPermission: "read", + projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId), + }, + ], + }); + + const surveys = await getSurveys(parsedInput.environmentId); + return surveys.map((survey) => transformToUnifySurvey(survey)); + }); diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connector-type-selector.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connector-type-selector.tsx new file mode 100644 index 0000000000..633bc083fa --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connector-type-selector.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { TConnectorType } from "@formbricks/types/connector"; +import { Badge } from "@/modules/ui/components/badge"; +import { getConnectorOptions } from "../utils"; + +interface ConnectorTypeSelectorProps { + selectedType: TConnectorType | null; + onSelectType: (type: TConnectorType) => void; +} + +export function ConnectorTypeSelector({ selectedType, onSelectType }: ConnectorTypeSelectorProps) { + const { t } = useTranslation(); + const connectorOptions = getConnectorOptions(t); + + return ( +
+

{t("environments.unify.select_source_type_prompt")}

+
+ {connectorOptions.map((option) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connectors-page-client.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connectors-page-client.tsx new file mode 100644 index 0000000000..d7657b94eb --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connectors-page-client.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { TConnectorType, TConnectorWithMappings, THubTargetField } from "@formbricks/types/connector"; +import { + createConnectorWithMappingsAction, + deleteConnectorAction, + updateConnectorWithMappingsAction, +} from "@/lib/connector/actions"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; +import { PageHeader } from "@/modules/ui/components/page-header"; +import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation"; +import { TFieldMapping, TUnifySurvey } from "../types"; +import { ConnectorsTable } from "./connectors-table"; +import { CreateConnectorModal } from "./create-connector-modal"; +import { EditConnectorModal } from "./edit-connector-modal"; + +interface ConnectorsSectionProps { + environmentId: string; + initialConnectors: TConnectorWithMappings[]; + initialSurveys: TUnifySurvey[]; +} + +export function ConnectorsSection({ + environmentId, + initialConnectors, + initialSurveys, +}: ConnectorsSectionProps) { + const { t } = useTranslation(); + const router = useRouter(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [editingConnector, setEditingConnector] = useState(null); + + const handleCreateConnector = async (data: { + name: string; + type: TConnectorType; + surveyId?: string; + elementIds?: string[]; + fieldMappings?: TFieldMapping[]; + }) => { + const result = await createConnectorWithMappingsAction({ + environmentId, + connectorInput: { + name: data.name, + type: data.type, + }, + formbricksMappings: + data.type === "formbricks" && data.surveyId && data.elementIds?.length + ? { surveyId: data.surveyId, elementIds: data.elementIds } + : undefined, + fieldMappings: + data.type !== "formbricks" && data.fieldMappings?.length + ? data.fieldMappings.map((m) => ({ + sourceFieldId: m.sourceFieldId || "", + targetFieldId: m.targetFieldId as THubTargetField, + staticValue: m.staticValue, + })) + : undefined, + }); + + if (!result?.data) { + toast.error(getFormattedErrorMessage(result)); + return; + } + + toast.success(t("environments.unify.connector_created_successfully")); + router.refresh(); + }; + + const handleUpdateConnector = async (data: { + connectorId: string; + environmentId: string; + name: string; + surveyId?: string; + elementIds?: string[]; + fieldMappings?: TFieldMapping[]; + }) => { + const result = await updateConnectorWithMappingsAction({ + connectorId: data.connectorId, + environmentId, + connectorInput: { + name: data.name, + }, + formbricksMappings: + data.surveyId && data.elementIds?.length + ? { surveyId: data.surveyId, elementIds: data.elementIds } + : undefined, + fieldMappings: data.fieldMappings?.length + ? data.fieldMappings.map((m) => ({ + sourceFieldId: m.sourceFieldId || "", + targetFieldId: m.targetFieldId as THubTargetField, + staticValue: m.staticValue, + })) + : undefined, + }); + + if (!result?.data) { + toast.error(getFormattedErrorMessage(result)); + return; + } + + toast.success(t("environments.unify.connector_updated_successfully")); + router.refresh(); + }; + + const handleDeleteConnector = async (connectorId: string) => { + const result = await deleteConnectorAction({ connectorId, environmentId }); + + if (!result?.data) { + toast.error(getFormattedErrorMessage(result)); + return; + } + + toast.success(t("environments.unify.connector_deleted_successfully")); + router.refresh(); + }; + + return ( + + + }> + + + +
+ +
+ + !open && setEditingConnector(null)} + onUpdateConnector={handleUpdateConnector} + onDeleteConnector={handleDeleteConnector} + surveys={initialSurveys} + /> +
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connectors-table-data-row.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connectors-table-data-row.tsx new file mode 100644 index 0000000000..239c43f83b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connectors-table-data-row.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { TConnectorStatus, TConnectorType } from "@formbricks/types/connector"; +import { Badge } from "@/modules/ui/components/badge"; + +const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [ + { amount: 60, unit: "seconds" }, + { amount: 60, unit: "minutes" }, + { amount: 24, unit: "hours" }, + { amount: 7, unit: "days" }, + { amount: 4.345, unit: "weeks" }, + { amount: 12, unit: "months" }, + { amount: Number.POSITIVE_INFINITY, unit: "years" }, +]; + +function getRelativeTime(date: Date, locale: string): string { + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + let duration = (date.getTime() - Date.now()) / 1000; + + for (const division of RELATIVE_TIME_DIVISIONS) { + if (Math.abs(duration) < division.amount) { + return formatter.format(Math.round(duration), division.unit); + } + duration /= division.amount; + } + + return formatter.format(Math.round(duration), "years"); +} + +interface ConnectorsTableDataRowProps { + id: string; + name: string; + type: TConnectorType; + status: TConnectorStatus; + mappingsCount: number; + createdAt: Date; + onClick: () => void; +} + +function getConnectorIcon(type: TConnectorType) { + switch (type) { + case "formbricks": + return ; + case "csv": + return ; + default: + return ; + } +} + +const STATUS_BADGE_TYPE: Record = { + active: "success", + paused: "warning", + error: "error", +}; + +export function ConnectorsTableDataRow({ + id, + name, + type, + status, + mappingsCount, + createdAt, + onClick, +}: ConnectorsTableDataRowProps) { + const { t, i18n } = useTranslation(); + + const getStatusLabel = (s: TConnectorStatus) => { + switch (s) { + case "active": + return t("environments.unify.status_active"); + case "paused": + return t("environments.unify.status_paused"); + case "error": + return t("environments.unify.status_error"); + } + }; + + const getConnectorTypeLabel = (connectorType: TConnectorType) => { + switch (connectorType) { + case "formbricks": + return t("environments.unify.formbricks_surveys"); + case "csv": + return t("environments.unify.csv_import"); + default: + return connectorType; + } + }; + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + onClick(); + } + }}> +
+ {getConnectorIcon(type)} + + {getConnectorTypeLabel(type)} + +
+
+ {name} +
+
+ +
+
+ {mappingsCount} {mappingsCount === 1 ? t("environments.unify.field") : t("environments.unify.fields")} +
+
+ {getRelativeTime(createdAt, i18n.language)} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connectors-table.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connectors-table.tsx new file mode 100644 index 0000000000..909dab4118 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connectors-table.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Loader2Icon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { TConnectorWithMappings } from "@formbricks/types/connector"; +import { ConnectorsTableDataRow } from "./connectors-table-data-row"; + +interface ConnectorsTableProps { + connectors: TConnectorWithMappings[]; + onConnectorClick: (connector: TConnectorWithMappings) => void; + isLoading?: boolean; +} + +export function ConnectorsTable({ connectors, onConnectorClick, isLoading = false }: ConnectorsTableProps) { + const { t } = useTranslation(); + + return ( +
+
+
{t("common.type")}
+
{t("common.name")}
+
{t("common.status")}
+
{t("common.mappings")}
+
{t("common.created")}
+
+ {isLoading ? ( +
+ +
+ ) : connectors.length === 0 ? ( +
+

{t("environments.unify.no_sources_connected")}

+
+ ) : ( +
+ {connectors.map((connector) => ( + onConnectorClick(connector)} + /> + ))} +
+ )} +
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/create-connector-modal.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/create-connector-modal.tsx new file mode 100644 index 0000000000..73f5cd8e12 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/create-connector-modal.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { TConnectorType } from "@formbricks/types/connector"; +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 { + FEEDBACK_RECORD_FIELDS, + TCreateConnectorStep, + TFieldMapping, + TSourceField, + TUnifySurvey, +} from "../types"; +import { parseCSVColumnsToFields } from "../utils"; +import { ConnectorTypeSelector } from "./connector-type-selector"; +import { CsvConnectorUI } from "./csv-connector-ui"; +import { FormbricksSurveySelector } from "./formbricks-survey-selector"; + +interface CreateConnectorModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreateConnector: (data: { + name: string; + type: TConnectorType; + surveyId?: string; + elementIds?: string[]; + fieldMappings?: TFieldMapping[]; + }) => Promise; + surveys: TUnifySurvey[]; +} + +export const CreateConnectorModal = ({ + open, + onOpenChange, + onCreateConnector, + surveys, +}: CreateConnectorModalProps) => { + const { t } = useTranslation(); + + const defaultConnectorName: Record = { + formbricks: t("environments.unify.default_connector_name_formbricks"), + csv: t("environments.unify.default_connector_name_csv"), + }; + const [currentStep, setCurrentStep] = useState("selectType"); + const [selectedType, setSelectedType] = useState(null); + const [connectorName, setConnectorName] = useState(""); + const [mappings, setMappings] = useState([]); + const [sourceFields, setSourceFields] = useState([]); + + const [selectedSurveyId, setSelectedSurveyId] = useState(null); + const [selectedElementIds, setSelectedElementIds] = useState([]); + + const resetForm = () => { + setCurrentStep("selectType"); + setSelectedType(null); + setConnectorName(""); + setMappings([]); + setSourceFields([]); + setSelectedSurveyId(null); + setSelectedElementIds([]); + }; + + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + resetForm(); + } + onOpenChange(newOpen); + }; + + const handleNextStep = () => { + if (currentStep === "selectType" && selectedType) { + if (selectedType === "formbricks") { + const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId); + + setConnectorName( + selectedSurvey + ? `${selectedSurvey.name} ${t("environments.unify.connection")}` + : defaultConnectorName[selectedType] + ); + } else { + setConnectorName(defaultConnectorName[selectedType]); + } + + setCurrentStep("mapping"); + } + }; + + const handleSurveySelect = (surveyId: string | null) => { + setSelectedSurveyId(surveyId); + }; + + const handleElementToggle = (elementId: string) => { + setSelectedElementIds((prev) => + prev.includes(elementId) ? prev.filter((id) => id !== elementId) : [...prev, elementId] + ); + }; + + const handleSelectAllElements = (surveyId: string) => { + const survey = surveys.find((s) => s.id === surveyId); + if (survey) { + setSelectedElementIds(survey.elements.map((e) => e.id)); + } + }; + + const handleDeselectAllElements = () => { + setSelectedElementIds([]); + }; + + const handleBack = () => { + if (currentStep === "mapping") { + setCurrentStep("selectType"); + setMappings([]); + setSourceFields([]); + } + }; + + const handleCreate = async () => { + if (!selectedType || !connectorName.trim()) return; + + if (selectedType !== "formbricks") { + const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required); + const allRequired = requiredFields.every((field) => mappings.some((m) => m.targetFieldId === field.id)); + + if (!allRequired) { + console.warn("Not all required fields are mapped"); + } + } + + await onCreateConnector({ + name: connectorName.trim(), + type: selectedType, + surveyId: selectedType === "formbricks" ? (selectedSurveyId ?? undefined) : undefined, + elementIds: selectedType === "formbricks" ? selectedElementIds : undefined, + fieldMappings: selectedType !== "formbricks" && mappings.length > 0 ? mappings : undefined, + }); + resetForm(); + onOpenChange(false); + }; + + const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required); + const allRequiredMapped = requiredFields.every((field) => + mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue)) + ); + + const isFormbricksValid = + selectedType === "formbricks" && selectedSurveyId && selectedElementIds.length > 0; + const isCsvValid = selectedType === "csv" && sourceFields.length > 0; + + const handleLoadSourceFields = () => { + if (selectedType === "csv") { + const fields = parseCSVColumnsToFields("timestamp,customer_id,rating,feedback_text,category"); + setSourceFields(fields); + } + }; + + return ( + <> + + + + + + + {currentStep === "selectType" + ? t("environments.unify.add_feedback_source") + : selectedType === "formbricks" + ? t("environments.unify.select_survey_and_questions") + : selectedType === "csv" + ? t("environments.unify.import_csv_data") + : t("environments.unify.configure_mapping")} + + + {currentStep === "selectType" + ? t("environments.unify.select_source_type_description") + : selectedType === "formbricks" + ? t("environments.unify.select_survey_questions_description") + : selectedType === "csv" + ? t("environments.unify.upload_csv_data_description") + : t("environments.unify.configure_mapping")} + + + +
+ {currentStep === "selectType" ? ( + + ) : selectedType === "formbricks" ? ( +
+
+ + setConnectorName(e.target.value)} + placeholder={t("environments.unify.enter_name_for_source")} + /> +
+ +
+ +
+
+ ) : selectedType === "csv" ? ( +
+
+ + setConnectorName(e.target.value)} + placeholder={t("environments.unify.enter_name_for_source")} + /> +
+ +
+ +
+
+ ) : null} +
+ + + {currentStep === "mapping" && ( + + )} + {currentStep === "selectType" ? ( + + ) : ( + + )} + +
+
+ + ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/csv-connector-ui.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/csv-connector-ui.tsx new file mode 100644 index 0000000000..9610ec7faa --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/csv-connector-ui.tsx @@ -0,0 +1,358 @@ +"use client"; + +import { parse } from "csv-parse/sync"; +import { + ArrowUpFromLineIcon, + CloudIcon, + CopyIcon, + FolderIcon, + RefreshCwIcon, + SettingsIcon, +} from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert } from "@/modules/ui/components/alert"; +import { Badge } from "@/modules/ui/components/badge"; +import { Button } from "@/modules/ui/components/button"; +import { Label } from "@/modules/ui/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/ui/components/select"; +import { Switch } from "@/modules/ui/components/switch"; +import { MAX_CSV_VALUES, TFieldMapping, TSourceField, createFeedbackCSVDataSchema } from "../types"; +import { MappingUI } from "./mapping-ui"; + +interface CsvConnectorUIProps { + sourceFields: TSourceField[]; + mappings: TFieldMapping[]; + onMappingsChange: (mappings: TFieldMapping[]) => void; + onSourceFieldsChange: (fields: TSourceField[]) => void; + onLoadSampleCSV: () => void; +} + +export function CsvConnectorUI({ + sourceFields, + mappings, + onMappingsChange, + onSourceFieldsChange, + onLoadSampleCSV, +}: CsvConnectorUIProps) { + const { t } = useTranslation(); + const [csvFile, setCsvFile] = useState(null); + const [csvPreview, setCsvPreview] = useState([]); + const [showMapping, setShowMapping] = useState(false); + const [csvError, setCsvError] = useState(""); + const [s3AutoSync, setS3AutoSync] = useState(false); + const [s3Copied, setS3Copied] = useState(false); + + const s3BucketName = "formbricks-feedback-imports"; + const s3Path = `s3://${s3BucketName}/feedback/incoming/`; + + const handleCopyS3Path = () => { + navigator.clipboard.writeText(s3Path); + setS3Copied(true); + setTimeout(() => setS3Copied(false), 2000); + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target?.files?.[0]; + if (file) { + processCSVFile(file); + } + }; + + const processCSVFile = (file: File) => { + setCsvError(""); + + if (!file.name.endsWith(".csv")) { + setCsvError(t("environments.unify.csv_files_only")); + return; + } + + if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) { + setCsvError(t("environments.unify.csv_files_only")); + return; + } + + if (file.size > MAX_CSV_VALUES.FILE_SIZE) { + setCsvError(t("environments.unify.csv_file_too_large")); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const csv = e.target?.result as string; + + try { + const records = parse(csv, { columns: true, skip_empty_lines: true }); + + const result = createFeedbackCSVDataSchema(t).safeParse(records); + if (!result.success) { + setCsvError(result.error.errors[0].message); + return; + } + + const validRecords = result.data; + const headers = Object.keys(validRecords[0]); + + const preview: string[][] = [ + headers, + ...validRecords.slice(0, 5).map((row) => headers.map((h) => row[h] ?? "")), + ]; + setCsvFile(file); + setCsvPreview(preview); + + const fields: TSourceField[] = headers.map((header) => ({ + id: header, + name: header, + type: "string", + sampleValue: validRecords[0][header] ?? "", + })); + onSourceFieldsChange(fields); + setShowMapping(true); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to parse CSV"; + setCsvError(message); + } + }; + reader.readAsText(file); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + const file = e.dataTransfer.files[0]; + if (file) { + processCSVFile(file); + } + }; + + const handleLoadSample = () => { + onLoadSampleCSV(); + setShowMapping(true); + }; + + if (showMapping && sourceFields.length > 0) { + return ( +
+ {csvFile && ( +
+
+ + {csvFile.name} + +
+ +
+ )} + + {csvPreview.length > 0 && ( +
+
+ + + + {csvPreview[0]?.map((header, i) => ( + + ))} + + + + {csvPreview.slice(1, 4).map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {header} +
+ {cell || } +
+
+ {csvPreview.length > 4 && ( +
+ {t("environments.unify.showing_rows", { count: csvPreview.length - 1 })} +
+ )} +
+ )} + + +
+ ); + } + + return ( +
+ {csvError && ( + + {csvError} + + )} +
+

{t("environments.unify.upload_csv_file")}

+
+ +
+
+ +
+
+ +
+
+ {t("environments.unify.or")} +
+
+ +
+
+ +

+ {t("environments.unify.s3_bucket_integration")} +

+ +
+ +
+

{t("environments.unify.s3_bucket_description")}

+ +
+
+ +
+ + {s3Path} + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + {t("environments.unify.enable_auto_sync")} + + + {t("environments.unify.process_new_files_description")} + +
+ +
+ +
+
+ +
+

+ {t("environments.unify.iam_configuration_required")} +

+

+ {t("environments.unify.iam_setup_instructions")}{" "} + +

+
+
+
+ +
+ +
+
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/edit-connector-modal.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/edit-connector-modal.tsx new file mode 100644 index 0000000000..29c3e5f505 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/edit-connector-modal.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector"; +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 { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, TUnifySurvey } from "../types"; +import { parseCSVColumnsToFields } from "../utils"; +import { FormbricksSurveySelector } from "./formbricks-survey-selector"; +import { MappingUI } from "./mapping-ui"; + +interface EditConnectorModalProps { + connector: TConnectorWithMappings | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onUpdateConnector: (data: { + connectorId: string; + environmentId: string; + name: string; + surveyId?: string; + elementIds?: string[]; + fieldMappings?: TFieldMapping[]; + }) => Promise; + onDeleteConnector: (connectorId: string) => Promise; + surveys: TUnifySurvey[]; +} + +function getConnectorIcon(type: TConnectorType) { + switch (type) { + case "formbricks": + return ; + case "csv": + return ; + default: + return ; + } +} + +function getConnectorTypeLabelKey(type: TConnectorType): string { + switch (type) { + case "formbricks": + return "environments.unify.formbricks_surveys"; + case "csv": + return "environments.unify.csv_import"; + default: + return type; + } +} + +export function EditConnectorModal({ + connector, + open, + onOpenChange, + onUpdateConnector, + onDeleteConnector, + surveys, +}: EditConnectorModalProps) { + const { t } = useTranslation(); + const [connectorName, setConnectorName] = useState(""); + const [mappings, setMappings] = useState([]); + const [sourceFields, setSourceFields] = useState([]); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const [selectedSurveyId, setSelectedSurveyId] = useState(null); + const [selectedElementIds, setSelectedElementIds] = useState([]); + + useEffect(() => { + if (connector) { + setConnectorName(connector.name); + + if (connector.type === "formbricks") { + const fbMappings = connector.formbricksMappings; + setSelectedSurveyId(fbMappings.length > 0 ? fbMappings[0].surveyId : null); + setSelectedElementIds(fbMappings.map((m) => m.elementId)); + setSourceFields([]); + setMappings([]); + } else if (connector.type === "csv") { + const columnsFromMappings = [ + ...new Set(connector.fieldMappings.map((m) => m.sourceFieldId).filter(Boolean)), + ]; + setSourceFields( + columnsFromMappings.length > 0 + ? parseCSVColumnsToFields(columnsFromMappings.join(",")) + : parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS) + ); + setMappings( + connector.fieldMappings.map((m) => ({ + sourceFieldId: m.sourceFieldId, + targetFieldId: m.targetFieldId, + staticValue: m.staticValue ?? undefined, + })) + ); + setSelectedSurveyId(null); + setSelectedElementIds([]); + } else { + setSourceFields([]); + setMappings([]); + setSelectedSurveyId(null); + setSelectedElementIds([]); + } + } + }, [connector]); + + const resetForm = () => { + setConnectorName(""); + setMappings([]); + setSourceFields([]); + setShowDeleteConfirm(false); + setSelectedSurveyId(null); + setSelectedElementIds([]); + }; + + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + resetForm(); + } + onOpenChange(newOpen); + }; + + const handleSurveySelect = (surveyId: string | null) => { + setSelectedSurveyId(surveyId); + }; + + const handleElementToggle = (elementId: string) => { + setSelectedElementIds((prev) => + prev.includes(elementId) ? prev.filter((id) => id !== elementId) : [...prev, elementId] + ); + }; + + const handleSelectAllElements = (surveyId: string) => { + const survey = surveys.find((s) => s.id === surveyId); + if (survey) { + setSelectedElementIds(survey.elements.map((e) => e.id)); + } + }; + + const handleDeselectAllElements = () => { + setSelectedElementIds([]); + }; + + const handleUpdate = async () => { + if (!connector || !connectorName.trim()) return; + + await onUpdateConnector({ + connectorId: connector.id, + environmentId: connector.environmentId, + name: connectorName.trim(), + surveyId: connector.type === "formbricks" ? (selectedSurveyId ?? undefined) : undefined, + elementIds: connector.type === "formbricks" ? selectedElementIds : undefined, + fieldMappings: connector.type !== "formbricks" && mappings.length > 0 ? mappings : undefined, + }); + handleOpenChange(false); + }; + + const handleDelete = async () => { + if (!connector) return; + await onDeleteConnector(connector.id); + handleOpenChange(false); + }; + + if (!connector) return null; + + return ( + + + + {t("environments.unify.edit_source_connection")} + {t("environments.unify.update_mapping_description")} + + +
+
+ {getConnectorIcon(connector.type)} +
+

+ {t(getConnectorTypeLabelKey(connector.type))} +

+

+ {t("environments.unify.source_type_cannot_be_changed")} +

+
+
+ +
+ + setConnectorName(e.target.value)} + placeholder={t("environments.unify.enter_name_for_source")} + /> +
+ + {connector.type === "formbricks" ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ + +
+ {showDeleteConfirm ? ( +
+ {t("environments.unify.are_you_sure")} + + +
+ ) : ( + + )} +
+ +
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/formbricks-survey-selector.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/formbricks-survey-selector.tsx new file mode 100644 index 0000000000..ff4f4bfce7 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/formbricks-survey-selector.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { + CheckCircle2Icon, + CheckIcon, + ChevronDownIcon, + ChevronRightIcon, + CircleIcon, + FileTextIcon, + MessageSquareTextIcon, + StarIcon, +} from "lucide-react"; +import { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements"; +import { Badge } from "@/modules/ui/components/badge"; +import { TUnifySurvey } from "../types"; + +interface FormbricksSurveySelectorProps { + surveys: TUnifySurvey[]; + selectedSurveyId: string | null; + selectedElementIds: string[]; + onSurveySelect: (surveyId: string | null) => void; + onElementToggle: (elementId: string) => void; + onSelectAllElements: (surveyId: string) => void; + onDeselectAllElements: () => void; +} + +function getElementIcon(type: string) { + switch (type) { + case "openText": + return ; + case "rating": + case "nps": + return ; + default: + return ; + } +} + +export function FormbricksSurveySelector({ + surveys, + selectedSurveyId, + selectedElementIds, + onSurveySelect, + onElementToggle, + onSelectAllElements, + onDeselectAllElements, +}: FormbricksSurveySelectorProps) { + const { t } = useTranslation(); + const [expandedSurveyId, setExpandedSurveyId] = useState(null); + + const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId); + + const handleSurveyClick = (survey: TUnifySurvey) => { + if (selectedSurveyId === survey.id) { + setExpandedSurveyId(expandedSurveyId === survey.id ? null : survey.id); + } else { + onSurveySelect(survey.id); + onDeselectAllElements(); + setExpandedSurveyId(survey.id); + } + }; + + const allElementsSelected = selectedSurvey && selectedElementIds.length === selectedSurvey.elements.length; + + const getStatusBadge = (status: TUnifySurvey["status"]) => { + switch (status) { + case "active": + return ; + case "paused": + return ; + case "draft": + return ; + case "completed": + return ; + default: + return null; + } + }; + + return ( +
+ {/* Left: Survey List */} +
+

+ {t("environments.unify.select_survey")} +

+
+ {surveys.length === 0 ? ( +
+

{t("environments.unify.no_surveys_found")}

+
+ ) : ( + surveys.map((survey) => { + const isSelected = selectedSurveyId === survey.id; + const isExpanded = expandedSurveyId === survey.id; + + return ( +
+ +
+ ); + }) + )} +
+
+ + {/* Right: Element Selection */} +
+
+

{t("environments.unify.select_elements")}

+ {selectedSurvey && ( + + )} +
+ + {!selectedSurvey ? ( +
+

+ {t("environments.unify.select_a_survey_to_see_elements")} +

+
+ ) : selectedSurvey.elements.length === 0 ? ( +
+

{t("environments.unify.survey_has_no_elements")}

+
+ ) : ( +
+ {selectedSurvey.elements.map((element) => { + const isSelected = selectedElementIds.includes(element.id); + + return ( + + ); + })} + + {selectedElementIds.length > 0 && ( +
+

+ }} + /> +

+
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/mapping-field.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/mapping-field.tsx new file mode 100644 index 0000000000..a795475899 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/mapping-field.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useDraggable, useDroppable } from "@dnd-kit/core"; +import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Input } from "@/modules/ui/components/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/ui/components/select"; +import { TFieldMapping, TSourceField, TTargetField } from "../types"; + +interface DraggableSourceFieldProps { + field: TSourceField; + isMapped: boolean; +} + +export function DraggableSourceField({ field, isMapped }: DraggableSourceFieldProps) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + id: field.id, + data: field, + }); + + const style = transform + ? { + transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + } + : undefined; + + return ( +
+ +
+ {field.name} + ({field.type}) +
+ {field.sampleValue && ( + {field.sampleValue} + )} +
+ ); +} + +interface DroppableTargetFieldProps { + field: TTargetField; + mappedSourceField: TSourceField | null; + mapping: TFieldMapping | null; + onRemoveMapping: () => void; + onStaticValueChange: (value: string) => void; + isOver?: boolean; +} + +export function DroppableTargetField({ + field, + mappedSourceField, + mapping, + onRemoveMapping, + onStaticValueChange, + isOver, +}: DroppableTargetFieldProps) { + const { t } = useTranslation(); + const { setNodeRef, isOver: isOverCurrent } = useDroppable({ + id: field.id, + data: field, + }); + + const [isEditingStatic, setIsEditingStatic] = useState(false); + const [customValue, setCustomValue] = useState(""); + + const isActive = isOver || isOverCurrent; + const hasMapping = mappedSourceField || mapping?.staticValue; + + // Handle enum field type - show dropdown + if (field.type === "enum" && field.enumValues) { + return ( +
+
+
+ {field.name} + {field.required && *} + {t("environments.unify.enum")} +
+ +
+
+ ); + } + + // Handle string fields - allow drag & drop OR static value + if (field.type === "string") { + return ( +
+
+
+ {field.name} + {field.required && *} +
+ + {/* Show mapped source field */} + {mappedSourceField && !mapping?.staticValue && ( +
+ ← {mappedSourceField.name} + +
+ )} + + {/* Show static value */} + {mapping?.staticValue && !mappedSourceField && ( +
+ + = “{mapping.staticValue}” + + +
+ )} + + {/* Show input for entering static value when editing */} + {isEditingStatic && !hasMapping && ( +
+ setCustomValue(e.target.value)} + placeholder={ + field.exampleStaticValues + ? `e.g., ${field.exampleStaticValues[0]}` + : t("environments.unify.enter_value") + } + className="h-7 text-xs" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter" && customValue.trim()) { + onStaticValueChange(customValue.trim()); + setCustomValue(""); + setIsEditingStatic(false); + } + if (e.key === "Escape") { + setCustomValue(""); + setIsEditingStatic(false); + } + }} + /> + +
+ )} + + {/* Show example values as quick select OR drop zone */} + {!hasMapping && !isEditingStatic && ( +
+ {t("environments.unify.drop_field_or")} + + {field.exampleStaticValues && field.exampleStaticValues.length > 0 && ( + <> + | + {field.exampleStaticValues.slice(0, 3).map((val) => ( + + ))} + + )} +
+ )} +
+
+ ); + } + + // Helper to get display label for static values + const getStaticValueLabel = (value: string) => { + if (value === "$now") return t("environments.unify.feedback_date"); + return value; + }; + + // Default behavior for other field types (timestamp, float64, boolean, jsonb, etc.) + const hasDefaultMapping = mappedSourceField || mapping?.staticValue; + + return ( +
+
+
+ {field.name} + {field.required && *} + ({field.type}) +
+ + {/* Show mapped source field */} + {mappedSourceField && !mapping?.staticValue && ( +
+ ← {mappedSourceField.name} + +
+ )} + + {/* Show static value */} + {mapping?.staticValue && !mappedSourceField && ( +
+ + = {getStaticValueLabel(mapping.staticValue)} + + +
+ )} + + {/* Show drop zone with preset options */} + {!hasDefaultMapping && ( +
+ {t("environments.unify.drop_a_field_here")} + {field.exampleStaticValues && field.exampleStaticValues.length > 0 && ( + <> + | + {field.exampleStaticValues.map((val) => ( + + ))} + + )} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/mapping-ui.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/mapping-ui.tsx new file mode 100644 index 0000000000..07768cc1e7 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/components/mapping-ui.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { TConnectorType } from "@formbricks/types/connector"; +import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField } from "../types"; +import { DraggableSourceField, DroppableTargetField } from "./mapping-field"; + +interface MappingUIProps { + sourceFields: TSourceField[]; + mappings: TFieldMapping[]; + onMappingsChange: (mappings: TFieldMapping[]) => void; + connectorType: TConnectorType; +} + +export function MappingUI({ sourceFields, mappings, onMappingsChange, connectorType }: MappingUIProps) { + const { t } = useTranslation(); + const [activeId, setActiveId] = useState(null); + + const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required); + const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required); + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + + if (!over) return; + + const sourceFieldId = active.id as string; + const targetFieldId = over.id as string; + + const newMappings = mappings.filter( + (m) => m.sourceFieldId !== sourceFieldId && m.targetFieldId !== targetFieldId + ); + onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]); + }; + + const handleRemoveMapping = (targetFieldId: string) => { + onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId)); + }; + + const handleStaticValueChange = (targetFieldId: string, staticValue: string) => { + const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId); + onMappingsChange([...newMappings, { targetFieldId, staticValue }]); + }; + + const getSourceFieldById = (id: string) => sourceFields.find((f) => f.id === id); + + const getMappingForTarget = (targetFieldId: string) => { + return mappings.find((m) => m.targetFieldId === targetFieldId) ?? null; + }; + + const getMappedSourceField = (targetFieldId: string) => { + const mapping = getMappingForTarget(targetFieldId); + return mapping?.sourceFieldId ? getSourceFieldById(mapping.sourceFieldId) : null; + }; + + const isSourceFieldMapped = (sourceFieldId: string) => + mappings.some((m) => m.sourceFieldId === sourceFieldId); + + const activeField = activeId ? getSourceFieldById(activeId) : null; + + return ( + +
+ {/* Source Fields Panel */} +
+

+ {connectorType === "csv" + ? t("environments.unify.csv_columns") + : t("environments.unify.source_fields")} +

+ + {sourceFields.length === 0 ? ( +
+

+ {connectorType === "csv" + ? t("environments.unify.click_load_sample_csv") + : t("environments.unify.no_source_fields_loaded")} +

+
+ ) : ( +
+ {sourceFields.map((field) => ( + + ))} +
+ )} +
+ + {/* Target Fields Panel */} +
+

+ {t("environments.unify.hub_feedback_record_fields")} +

+ + {/* Required Fields */} +
+

+ {t("environments.unify.required")} +

+ {requiredFields.map((field) => ( + handleRemoveMapping(field.id)} + onStaticValueChange={(value) => handleStaticValueChange(field.id, value)} + /> + ))} +
+ + {/* Optional Fields */} +
+

+ {t("environments.unify.optional")} +

+ {optionalFields.map((field) => ( + handleRemoveMapping(field.id)} + onStaticValueChange={(value) => handleStaticValueChange(field.id, value)} + /> + ))} +
+
+
+ + + {activeField ? ( +
+ {activeField.name} + ({activeField.type}) +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/lib.test.ts b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/lib.test.ts new file mode 100644 index 0000000000..34709160a2 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/lib.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test, vi } from "vitest"; +import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { transformToUnifySurvey } from "./lib"; + +vi.mock("@formbricks/types/surveys/validation", () => ({ + getTextContent: (str: string) => str, +})); + +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: (val: Record, _lang: string) => val?.default ?? "", +})); + +vi.mock("@/lib/survey/utils", () => ({ + getElementsFromBlocks: (blocks: Array<{ elements: unknown[] }>) => + blocks.flatMap((block) => block.elements), +})); + +vi.mock("@/lib/utils/recall", () => ({ + recallToHeadline: (headline: Record) => headline, +})); + +const NOW = new Date("2026-02-24T10:00:00.000Z"); + +const createMockSurvey = (overrides: Partial = {}): TSurvey => + ({ + id: "survey-1", + name: "Test Survey", + status: "inProgress", + createdAt: NOW, + blocks: [ + { + elements: [ + { + id: "el-text", + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "What do you think?" }, + required: true, + }, + { + id: "el-nps", + type: TSurveyElementTypeEnum.NPS, + headline: { default: "How likely to recommend?" }, + required: false, + }, + ], + }, + ], + ...overrides, + }) as unknown as TSurvey; + +describe("transformToUnifySurvey", () => { + test("transforms a survey with basic elements", () => { + const result = transformToUnifySurvey(createMockSurvey()); + + expect(result).toEqual({ + id: "survey-1", + name: "Test Survey", + status: "active", + createdAt: NOW, + elements: [ + { + id: "el-text", + type: TSurveyElementTypeEnum.OpenText, + headline: "What do you think?", + required: true, + }, + { + id: "el-nps", + type: TSurveyElementTypeEnum.NPS, + headline: "How likely to recommend?", + required: false, + }, + ], + }); + }); + + test("filters out CTA elements", () => { + const survey = createMockSurvey({ + blocks: [ + { + elements: [ + { + id: "el-text", + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "Feedback" }, + required: true, + }, + { + id: "el-cta", + type: TSurveyElementTypeEnum.CTA, + headline: { default: "Click here" }, + required: false, + }, + ], + }, + ], + } as Partial); + + const result = transformToUnifySurvey(survey); + + expect(result.elements).toHaveLength(1); + expect(result.elements[0].id).toBe("el-text"); + }); + + test("defaults required to false when not set", () => { + const survey = createMockSurvey({ + blocks: [ + { + elements: [ + { + id: "el-1", + type: TSurveyElementTypeEnum.Rating, + headline: { default: "Rate us" }, + }, + ], + }, + ], + } as Partial); + + const result = transformToUnifySurvey(survey); + expect(result.elements[0].required).toBe(false); + }); + + test("falls back to 'Untitled' when headline is empty", () => { + const survey = createMockSurvey({ + blocks: [ + { + elements: [ + { + id: "el-1", + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "" }, + required: false, + }, + ], + }, + ], + } as Partial); + + const result = transformToUnifySurvey(survey); + expect(result.elements[0].headline).toBe("Untitled"); + }); + + describe("mapSurveyStatus", () => { + test("maps 'inProgress' to 'active'", () => { + const result = transformToUnifySurvey(createMockSurvey({ status: "inProgress" } as Partial)); + expect(result.status).toBe("active"); + }); + + test("maps 'paused' to 'paused'", () => { + const result = transformToUnifySurvey(createMockSurvey({ status: "paused" } as Partial)); + expect(result.status).toBe("paused"); + }); + + test("maps 'draft' to 'draft'", () => { + const result = transformToUnifySurvey(createMockSurvey({ status: "draft" } as Partial)); + expect(result.status).toBe("draft"); + }); + + test("maps 'completed' to 'completed'", () => { + const result = transformToUnifySurvey(createMockSurvey({ status: "completed" } as Partial)); + expect(result.status).toBe("completed"); + }); + + test("maps unknown status to 'draft'", () => { + const result = transformToUnifySurvey(createMockSurvey({ status: "archived" } as Partial)); + expect(result.status).toBe("draft"); + }); + }); + + test("handles multiple blocks", () => { + const survey = createMockSurvey({ + blocks: [ + { + elements: [ + { + id: "el-1", + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "Q1" }, + required: true, + }, + ], + }, + { + elements: [ + { id: "el-2", type: TSurveyElementTypeEnum.Rating, headline: { default: "Q2" }, required: false }, + ], + }, + ], + } as Partial); + + const result = transformToUnifySurvey(survey); + expect(result.elements).toHaveLength(2); + expect(result.elements[0].id).toBe("el-1"); + expect(result.elements[1].id).toBe("el-2"); + }); + + test("handles empty blocks", () => { + const survey = createMockSurvey({ blocks: [] } as Partial); + const result = transformToUnifySurvey(survey); + expect(result.elements).toEqual([]); + }); + + test("preserves all element types except CTA", () => { + const elementTypes = [ + TSurveyElementTypeEnum.OpenText, + TSurveyElementTypeEnum.NPS, + TSurveyElementTypeEnum.Rating, + TSurveyElementTypeEnum.MultipleChoiceSingle, + TSurveyElementTypeEnum.MultipleChoiceMulti, + TSurveyElementTypeEnum.Date, + TSurveyElementTypeEnum.Consent, + TSurveyElementTypeEnum.Matrix, + TSurveyElementTypeEnum.Ranking, + TSurveyElementTypeEnum.PictureSelection, + TSurveyElementTypeEnum.ContactInfo, + TSurveyElementTypeEnum.Address, + TSurveyElementTypeEnum.FileUpload, + TSurveyElementTypeEnum.Cal, + TSurveyElementTypeEnum.CTA, + ]; + + const survey = createMockSurvey({ + blocks: [ + { + elements: elementTypes.map((type, i) => ({ + id: `el-${i.toString()}`, + type, + headline: { default: `Question ${i.toString()}` }, + required: false, + })), + }, + ], + } as Partial); + + const result = transformToUnifySurvey(survey); + const resultTypes = result.elements.map((e) => e.type); + + expect(resultTypes).not.toContain(TSurveyElementTypeEnum.CTA); + expect(result.elements).toHaveLength(elementTypes.length - 1); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/lib.ts b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/lib.ts new file mode 100644 index 0000000000..02b9f81681 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/lib.ts @@ -0,0 +1,51 @@ +import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { getTextContent } from "@formbricks/types/surveys/validation"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { getElementsFromBlocks } from "@/lib/survey/utils"; +import { recallToHeadline } from "@/lib/utils/recall"; +import { TUnifySurvey, TUnifySurveyElement } from "./types"; + +const getElementHeadline = (element: TSurveyElement, survey: TSurvey): string => { + return ( + getTextContent( + getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default") + ) || "Untitled" + ); +}; + +const mapSurveyStatus = (status: string): TUnifySurvey["status"] => { + switch (status) { + case "inProgress": + return "active"; + case "paused": + return "paused"; + case "draft": + return "draft"; + case "completed": + return "completed"; + default: + return "draft"; + } +}; + +export const transformToUnifySurvey = (survey: TSurvey): TUnifySurvey => { + const elements = getElementsFromBlocks(survey.blocks); + + const unifySurveyElements: TUnifySurveyElement[] = elements + .filter((el) => el.type !== TSurveyElementTypeEnum.CTA) + .map((el) => ({ + id: el.id, + type: el.type, + headline: getElementHeadline(el, survey), + required: el.required ?? false, + })); + + return { + id: survey.id, + name: survey.name, + status: mapSurveyStatus(survey.status), + elements: unifySurveyElements, + createdAt: survey.createdAt, + }; +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/page.tsx b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/page.tsx new file mode 100644 index 0000000000..49b12844e3 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/page.tsx @@ -0,0 +1,26 @@ +import { getConnectorsWithMappings } from "@/lib/connector/service"; +import { getSurveys } from "@/lib/survey/service"; +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { ConnectorsSection } from "./components/connectors-page-client"; +import { transformToUnifySurvey } from "./lib"; + +export default async function UnifySourcesPage(props: { params: Promise<{ environmentId: string }> }) { + const params = await props.params; + + await getEnvironmentAuth(params.environmentId); + + const [connectors, surveys] = await Promise.all([ + getConnectorsWithMappings(params.environmentId), + getSurveys(params.environmentId), + ]); + + const unifySurveys = surveys.map(transformToUnifySurvey); + + return ( + + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/types.ts b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/types.ts new file mode 100644 index 0000000000..066cf987fc --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/types.ts @@ -0,0 +1,215 @@ +import { TFunction } from "i18next"; +import { z } from "zod"; +import { THubFieldType, ZHubFieldType } from "@formbricks/types/connector"; +import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants"; + +export interface TUnifySurveyElement { + id: string; + type: TSurveyElementTypeEnum; + headline: string; + required: boolean; +} + +export interface TUnifySurvey { + id: string; + name: string; + status: "draft" | "active" | "paused" | "completed"; + elements: TUnifySurveyElement[]; + createdAt: Date; +} + +export interface TFieldMapping { + targetFieldId: string; + sourceFieldId?: string; + staticValue?: string; +} + +export type TTargetFieldType = "string" | "enum" | "timestamp" | "float64" | "boolean" | "jsonb" | "string[]"; + +export interface TTargetField { + id: string; + name: string; + type: TTargetFieldType; + required: boolean; + description: string; + enumValues?: THubFieldType[]; + exampleStaticValues?: string[]; +} + +export interface TSourceField { + id: string; + name: string; + type: string; + sampleValue?: string; +} + +export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [ + { + id: "collected_at", + name: "Collected At", + type: "timestamp", + required: true, + description: "When the feedback was originally collected", + exampleStaticValues: ["$now"], + }, + { + id: "source_type", + name: "Source Type", + type: "string", + required: true, + description: "Type of source (e.g., survey, review, support)", + exampleStaticValues: ["survey", "review", "support", "email", "qualtrics", "typeform", "intercom"], + }, + { + id: "field_id", + name: "Field ID", + type: "string", + required: true, + description: "Unique question/field identifier", + }, + { + id: "field_type", + name: "Field Type", + type: "enum", + required: true, + description: "Data type (text, nps, csat, rating, etc.)", + enumValues: ZHubFieldType.options, + }, + { + id: "tenant_id", + name: "Tenant ID", + type: "string", + required: false, + description: "Tenant/organization identifier for multi-tenant deployments", + }, + { + id: "source_id", + name: "Source ID", + type: "string", + required: false, + description: "Reference to survey/form/ticket/review ID", + }, + { + id: "source_name", + name: "Source Name", + type: "string", + required: false, + description: "Human-readable source name for display", + exampleStaticValues: ["Product Feedback", "Customer Support", "NPS Survey", "Qualtrics Import"], + }, + { + id: "field_label", + name: "Field Label", + type: "string", + required: false, + description: "Question text or field label for display", + }, + { + id: "field_group_id", + name: "Field Group ID", + type: "string", + required: false, + description: "Stable identifier grouping related fields (for ranking, matrix, grid questions)", + }, + { + id: "field_group_label", + name: "Field Group Label", + type: "string", + required: false, + description: "Human-readable question text for the group", + }, + { + id: "value_text", + name: "Value (Text)", + type: "string", + required: false, + description: "Text responses (feedback, comments, open-ended answers)", + }, + { + id: "value_number", + name: "Value (Number)", + type: "float64", + required: false, + description: "Numeric responses (ratings, scores, NPS, CSAT)", + }, + { + id: "value_boolean", + name: "Value (Boolean)", + type: "boolean", + required: false, + description: "Yes/no responses", + }, + { + id: "value_date", + name: "Value (Date)", + type: "timestamp", + required: false, + description: "Date/datetime responses", + }, + { + id: "metadata", + name: "Metadata", + type: "jsonb", + required: false, + description: "Flexible context (device, location, campaign, custom fields)", + }, + { + id: "language", + name: "Language", + type: "string", + required: false, + description: "ISO 639-1 language code (e.g., en, de, fr)", + exampleStaticValues: ["en", "de", "fr", "es", "pt", "ja", "zh"], + }, + { + id: "user_identifier", + name: "User Identifier", + type: "string", + required: false, + description: "Anonymous user ID for tracking (hashed, never PII)", + }, +]; + +export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category"; + +export const MAX_CSV_VALUES = { + FILE_SIZE: 2_097_152, // 2MB (2 * 1024 * 1024) + RECORDS: 1_000, // 1,000 records +} as const; + +export const createFeedbackCSVDataSchema = (t: TFunction) => + z + .array(z.record(z.string(), z.string())) + .min(1, { message: t("environments.unify.csv_at_least_one_row") }) + .max(MAX_CSV_VALUES.RECORDS, { + message: t("environments.unify.csv_max_records", { + max: MAX_CSV_VALUES.RECORDS.toLocaleString(), + }), + }) + .superRefine((rows, ctx) => { + const localeSort = (a: string, b: string) => a.localeCompare(b); + const firstRowKeys = Object.keys(rows[0]).sort(localeSort).join(","); + + for (let i = 1; i < rows.length; i++) { + const rowKeys = Object.keys(rows[i]).sort(localeSort).join(","); + if (rowKeys !== firstRowKeys) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("environments.unify.csv_inconsistent_columns", { row: (i + 1).toString() }), + }); + return; + } + } + + const emptyHeaders = Object.keys(rows[0]).filter((k) => k.trim() === ""); + if (emptyHeaders.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("environments.unify.csv_empty_column_headers"), + }); + } + }); + +export type TFeedbackCSVData = z.infer>; + +export type TCreateConnectorStep = "selectType" | "mapping"; diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/utils.test.ts b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/utils.test.ts new file mode 100644 index 0000000000..a538049648 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/utils.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "vitest"; +import { TSourceField } from "./types"; +import { getConnectorOptions, parseCSVColumnsToFields } from "./utils"; + +const mockT = (key: string) => key; + +describe("getConnectorOptions", () => { + test("returns formbricks and csv options", () => { + const options = getConnectorOptions(mockT as never); + expect(options).toHaveLength(2); + expect(options[0].id).toBe("formbricks"); + expect(options[1].id).toBe("csv"); + }); + + test("both options are enabled by default", () => { + const options = getConnectorOptions(mockT as never); + expect(options.every((o) => !o.disabled)).toBe(true); + }); + + test("uses translation keys for name and description", () => { + const options = getConnectorOptions(mockT as never); + expect(options[0].name).toBe("environments.unify.formbricks_surveys"); + expect(options[0].description).toBe("environments.unify.source_connect_formbricks_description"); + expect(options[1].name).toBe("environments.unify.csv_import"); + expect(options[1].description).toBe("environments.unify.source_connect_csv_description"); + }); +}); + +describe("parseCSVColumnsToFields", () => { + test("parses comma-separated column names into source fields", () => { + const result = parseCSVColumnsToFields("name,email,score"); + expect(result).toHaveLength(3); + expect(result).toEqual([ + { id: "name", name: "name", type: "string", sampleValue: "Sample name" }, + { id: "email", name: "email", type: "string", sampleValue: "Sample email" }, + { id: "score", name: "score", type: "string", sampleValue: "Sample score" }, + ]); + }); + + test("trims whitespace from column names", () => { + const result = parseCSVColumnsToFields(" name , email , score "); + expect(result[0].id).toBe("name"); + expect(result[1].id).toBe("email"); + expect(result[2].id).toBe("score"); + }); + + test("handles single column", () => { + const result = parseCSVColumnsToFields("feedback"); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("feedback"); + }); + + test("generates sample values from column names", () => { + const result = parseCSVColumnsToFields("rating,comment"); + expect(result[0].sampleValue).toBe("Sample rating"); + expect(result[1].sampleValue).toBe("Sample comment"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/utils.ts b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/utils.ts new file mode 100644 index 0000000000..73e6133f1e --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/workspace/unify/sources/utils.ts @@ -0,0 +1,32 @@ +import { TFunction } from "i18next"; +import { TSourceField } from "./types"; + +export interface TConnectorOption { + id: string; + name: string; + description: string; + disabled: boolean; + badge?: { text: string; type: "success" | "gray" | "warning" }; +} + +export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [ + { + id: "formbricks", + name: t("environments.unify.formbricks_surveys"), + description: t("environments.unify.source_connect_formbricks_description"), + disabled: false, + }, + { + id: "csv", + name: t("environments.unify.csv_import"), + description: t("environments.unify.source_connect_csv_description"), + disabled: false, + }, +]; + +export const parseCSVColumnsToFields = (columns: string): TSourceField[] => { + return columns.split(",").map((col) => { + const trimmed = col.trim(); + return { id: trimmed, name: trimmed, type: "string", sampleValue: `Sample ${trimmed}` }; + }); +}; diff --git a/apps/web/app/api/(internal)/pipeline/route.ts b/apps/web/app/api/(internal)/pipeline/route.ts index d43caa625e..4b65fe4b2a 100644 --- a/apps/web/app/api/(internal)/pipeline/route.ts +++ b/apps/web/app/api/(internal)/pipeline/route.ts @@ -8,6 +8,7 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { handleConnectorPipeline } from "@/lib/connector/pipeline-handler"; import { CRON_SECRET } from "@/lib/constants"; import { generateStandardWebhookSignature } from "@/lib/crypto"; import { getIntegrations } from "@/lib/integration/service"; @@ -141,6 +142,14 @@ export const POST = async (request: Request) => { }); if (event === "responseFinished") { + // Handle connector pipeline for Hub integration (only on responseFinished to avoid duplicates) + // This sends response data to the Hub for configured connectors + try { + await handleConnectorPipeline(response, survey, environmentId); + } catch (error) { + // Log but don't throw - connector failures shouldn't break the main pipeline + logger.error({ error, surveyId, responseId: response.id }, "Connector pipeline failed"); + } // Fetch integrations and responseCount in parallel const [integrations, responseCount] = await Promise.all([ getIntegrations(environmentId), diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 9e6b861daf..e966c57d06 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -114,6 +114,7 @@ checksums: common/app_survey: f076d131d20bfdadb35fba29c8275232 common/apply_filters: 6543c1e80038b3da0f4a42848d08d4d1 common/are_you_sure: 6d5cd13628a7887711fd0c29f1123652 + common/ask: 24150ae04c60dcd8688d93a8a3a2d238 common/attributes: 86d0ae6fea0fbb119722ed3841f8385a common/back: f541015a827e37cb3b1234e56bc2aa3c common/billing: b01dbdd049ebbd4a349fa64d6ce65a3b @@ -136,7 +137,7 @@ checksums: common/code: 343bc5386149b97cece2b093c39034b2 common/collapse_rows: 24988527f9180f37aa55d2aa183ccb21 common/completed: 0e4bbce9985f25eb673d9a054c8d5334 - common/configuration: 923ec0502721489202f6222dd4107163 + common/configure: e3ab18ebb36c218cd4897c620f5809ac common/confirm: 90930b51154032f119fa75c1bd422d8b common/connect: 8778ee245078a8be4a2ce855c8c56edc common/connect_formbricks: a9dd747575e7e035da69251366df6f95 @@ -172,6 +173,7 @@ checksums: common/disallow: 01c8ed3ce545ed836d3ccffc562c8a0c common/discard: de83a114a79d086e372c43dbfe9f47b4 common/dismissed: f0e21b3fe28726c577a7238a63cc29c2 + common/distribute: 0b702c85b5d4069d8367cb461c2ee0b1 common/docs: 1563fcb5ddb5037b0709ccd3dd384a92 common/documentation: 1563fcb5ddb5037b0709ccd3dd384a92 common/domain: 402d46965eacc3af4c5df92e53e95712 @@ -240,6 +242,7 @@ checksums: common/logout: 07948fdf20705e04a7bf68ab197512bf common/look_and_feel: 9125503712626d495cedec7a79f1418c common/manage: a3d40c0267b81ae53c9598eaeb05087d + common/mappings: 938751312ce179df491c94c1243546e7 common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8 common/member: 1606dc30b369856b9dba1fe9aec425d2 common/members: 0932e80cba1e3e0a7f52bb67ff31da32 @@ -401,6 +404,7 @@ checksums: common/top_right: 241f95c923846911aaf13af6109333e5 common/try_again: 33dd8820e743e35a66e6977f69e9d3b5 common/type: f04471a7ddac844b9ad145eb9911ef75 + common/unify: bdb518a1e62f51049ccc4366b909fb0a common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0 common/update: 079fc039262fd31b10532929685c2d1b common/updated: 8aa8ff2dc2977ca4b269e80a513100b4 @@ -1928,6 +1932,108 @@ checksums: environments/surveys/templates/multiple_industries: 7dcb6f6d87feb08f8004dfb5a91e711f environments/surveys/templates/use_this_template: 69020c8b5a521b8f027616bb5c4b64dd environments/surveys/templates/uses_branching_logic: 7ac087d7067d342c17809d4ce497dfe0 + environments/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45 + environments/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e + environments/unify/are_you_sure: 6d5cd13628a7887711fd0c29f1123652 + environments/unify/automated: 040d99fc1e8649d9bfac6e45759ff119 + environments/unify/aws_region: 6d7132311a69d6288cee9dbfec27227a + environments/unify/change_file: c5163ac18bf443370228a8ecbb0b07da + environments/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316 + environments/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b + environments/unify/configure_import: 71d550661f7e9fe322b60e7e870aa2fd + environments/unify/configure_mapping: c794411c50bc511f8fc332def0e4e2f9 + environments/unify/connection: 421e709602c92ffbe04a266f6a092089 + environments/unify/connector_created_successfully: ea927316021fb2a41cc69ca3ec89d0aa + environments/unify/connector_deleted_successfully: ea3c9842c5b8f75b02ecb9c80c74d780 + environments/unify/connector_updated_successfully: 11308c4a2881345209cefa06a3d90eab + environments/unify/copied: 0d1b21bf6919e363f5c4a4ac75dab210 + environments/unify/copy: 627c00d2c850b9b45f7341a6ac01b6bb + environments/unify/create_mapping: cbe8c951e7819f574ca7d793920b2b60 + environments/unify/csv_at_least_one_row: 165bbc1853dde85c44eb5a587c52ce28 + environments/unify/csv_columns: 280c5ba0b19ae5fa6d42f4d05a1771cb + environments/unify/csv_empty_column_headers: 6e9af154be54778cfca32296fbd23ecb + environments/unify/csv_file_too_large: e94c7a7c26096aae9eddb2db30c5cfc1 + environments/unify/csv_files_only: 920612b537521b14c154f1ac9843e947 + environments/unify/csv_import: ef4060fef24c4fec064987b9d2a9fa4b + environments/unify/csv_inconsistent_columns: 4a1b331f61018fc6721ab7557be32210 + environments/unify/csv_max_records: e0dda2a8f66feb4aa60aba327b8511d5 + environments/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b + environments/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110 + environments/unify/delete_source: f1efd5e1c403192a063b761ddfeaf34a + environments/unify/deselect_all: facf8871b2e84a454c6bfe40c2821922 + environments/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb + environments/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353 + environments/unify/drop_zone_path: 8e60cc5a0b7b74fe624cfdc0b11a884d + environments/unify/edit_source_connection: eb42476becc8de3de4ca9626828573f0 + environments/unify/element_selected: f194010dff50242e6f123e0a7da2094c + environments/unify/elements_selected: 058a38789415da7fc08b976cdcc1ac66 + environments/unify/enable_auto_sync: d23ed425a77525a905365631b068ab93 + environments/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3 + environments/unify/enter_value: 4f068bb59617975c1e546218373122cd + environments/unify/enum: 96fc644f35edd6b1c09d1d503f078acc + environments/unify/every_15_minutes: 82b7ca02645256b843b92e3629429f02 + environments/unify/every_30_minutes: 6bba217e921f55cad68948d6136d23c0 + environments/unify/every_5_minutes: 4ecba56de234044216c3db522f542109 + environments/unify/every_hour: 1314cadc59cef3d1f63f59c30f58fba1 + environments/unify/feedback_date: 4ada116cc8375dd67483108eeb0ddfe8 + environments/unify/field: 87d7b2d449e2231e5d75ff64015a8cf3 + environments/unify/fields: 3b02117e12872bf0cd2b6056728216e8 + environments/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a + environments/unify/hub_feedback_record_fields: d8e7b6bb8b7c45d8bd69e5f32193dde4 + environments/unify/iam_configuration_required: 2da3c3c5fd9de01c815204c33e0baf58 + environments/unify/iam_setup_instructions: f165c08df18347c0692a45b9fc846a6c + environments/unify/import_csv_data: e5f873b0e6116c5144677acf38607f2e + environments/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e + environments/unify/n_elements: 4a0906410e783ec98f58367eb0ce0f8c + environments/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640 + environments/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f + environments/unify/no_surveys_found: 649a2f29b4c34525778d9177605fb326 + environments/unify/optional: 396fb9a0472daf401c392bdc3e248943 + environments/unify/or: 7b133c38bec0d5ee23cc6bcf9a8de50b + environments/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c + environments/unify/process_new_files_description: a739c0cc86c92940fe1302ba1ad244e5 + environments/unify/processing_interval: ff66d16920ad6815efeaabf6f61fc260 + environments/unify/region_ap_southeast_1: 6932d3023a19c776499445f1f415394d + environments/unify/region_eu_central_1: 8476430efbe3f4edb9295d5c6e6d05f9 + environments/unify/region_eu_west_1: f543a1457e57e68c0b19e01bf7351b8f + environments/unify/region_us_east_1: 606343effd2647363eda831cb1fcc494 + environments/unify/region_us_west_2: 45165afea3626c112b9e850fb88c0d5d + environments/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5 + environments/unify/s3_bucket_description: 45ca98a3e925254b831969930ef00953 + environments/unify/s3_bucket_integration: 9095ce49ee205bb39065928b527c37fa + environments/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a + environments/unify/select_a_survey_to_see_elements: e549e92e8e2fda4fc6cfc62661a4b328 + environments/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862 + environments/unify/select_all: eedc7cdb02de467c15dc418a066a77f2 + environments/unify/select_elements: c336db5308ff54b1dd8b717fad7dbaff + environments/unify/select_questions: 13c79b8c284423eb6140534bf2137e56 + environments/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc + environments/unify/select_source_type_prompt: c3fce7d908ee62b9e1b7fab1b17606d7 + environments/unify/select_survey: bac52e59c7847417bef6fe7b7096b475 + environments/unify/select_survey_and_questions: 53914988a2f48caecea23f3b3b868b9f + environments/unify/select_survey_questions_description: 3386ed56085eabebefa3cc453269fc5b + environments/unify/set_value: b8a86f8da957ebd599ece4b1b1936a78 + environments/unify/setup_connection: cce7d9c488d737d04e70bed929a46f8a + environments/unify/showing_rows: 83d3440314d1e6f2721e034369a3a131 + environments/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746 + environments/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff + environments/unify/source_fields: 1bae074990e64cbfd820a0b6462397be + environments/unify/source_name: 157675beca12efcd8ec512c5256b1a61 + environments/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1 + environments/unify/sources: ecbbe6e49baa335c5afd7b04b609d006 + environments/unify/status_active: 3e1ec025c4a50830bbb9ad57a176630a + environments/unify/status_completed: 0e4bbce9985f25eb673d9a054c8d5334 + environments/unify/status_draft: e8a92958ad300aacfe46c2bf6644927e + environments/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248 + environments/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991 + environments/unify/survey_has_no_elements: 0379106932976c0a61119a20992d4b18 + environments/unify/test_connection: 6bddfcf3e2a1e806057514093a3fe071 + environments/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5 + environments/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396 + environments/unify/upload_csv_data_description: 61ff18cadfd21ef9820a203bb035d616 + environments/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2 + environments/unify/view_setup_guide: 3edf6288a06af663cff24a74cbcba235 + environments/unify/yes_delete: 7a260e784409a9112f77d213754cd3e0 environments/workspace/api_keys/add_api_key: 3c7633bae18a6e19af7a5af12f9bc3da environments/workspace/api_keys/api_key: ce825fec5b3e1f8e27c45b1a63619985 environments/workspace/api_keys/api_key_copied_to_clipboard: daeeac786ba09ffa650e206609b88f9c diff --git a/apps/web/lib/connector/actions.ts b/apps/web/lib/connector/actions.ts new file mode 100644 index 0000000000..3fc25584c8 --- /dev/null +++ b/apps/web/lib/connector/actions.ts @@ -0,0 +1,229 @@ +"use server"; + +import { z } from "zod"; +import { logger } from "@formbricks/logger"; +import { ZId } from "@formbricks/types/common"; +import { + TConnectorWithMappings, + ZConnectorCreateInput, + ZConnectorFieldMappingCreateInput, + ZConnectorUpdateInput, + getHubFieldTypeFromElementType, +} from "@formbricks/types/connector"; +import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { getSurvey } from "@/lib/survey/service"; +import { getElementsFromBlocks } from "@/lib/survey/utils"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { + getOrganizationIdFromConnectorId, + getOrganizationIdFromEnvironmentId, + getOrganizationIdFromSurveyId, + getProjectIdFromConnectorId, + getProjectIdFromEnvironmentId, +} from "@/lib/utils/helper"; +import { + TMappingsInput, + createConnectorWithMappings, + deleteConnector, + updateConnectorWithMappings, +} from "./service"; + +const ZDeleteConnectorAction = z.object({ + connectorId: ZId, + environmentId: ZId, +}); + +export const deleteConnectorAction = authenticatedActionClient + .schema(ZDeleteConnectorAction) + .action( + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: z.infer; + }) => { + const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId); + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + minPermission: "readWrite", + projectId: await getProjectIdFromConnectorId(parsedInput.connectorId), + }, + ], + }); + + return deleteConnector(parsedInput.connectorId, parsedInput.environmentId); + } + ); + +const resolveFormbricksMappingsInput = async ( + surveyId: string, + elementIds: string[] +): Promise => { + const survey = await getSurvey(surveyId); + if (!survey) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + const elements = getElementsFromBlocks(survey.blocks); + const elementMap = new Map(elements.map((el) => [el.id, el])); + + const mappings = elementIds + .filter((elementId) => { + if (elementMap.has(elementId)) return true; + logger.warn({ surveyId, elementId }, "Skipping unknown elementId when building connector mappings"); + return false; + }) + .map((elementId) => { + const element = elementMap.get(elementId)!; + return { + surveyId, + elementId, + hubFieldType: getHubFieldTypeFromElementType(element.type), + }; + }); + + return { type: "formbricks", mappings }; +}; + +const ZCreateConnectorWithMappingsAction = z.object({ + environmentId: ZId, + connectorInput: ZConnectorCreateInput, + formbricksMappings: z + .object({ + surveyId: ZId, + elementIds: z.array(z.string()).min(1), + }) + .optional(), + fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(), +}); + +export const createConnectorWithMappingsAction = authenticatedActionClient + .schema(ZCreateConnectorWithMappingsAction) + .action( + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: z.infer; + }): Promise => { + const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId); + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + minPermission: "readWrite", + projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId), + }, + ], + }); + + let mappingsInput: TMappingsInput | undefined; + + const { formbricksMappings, fieldMappings } = parsedInput; + + if (formbricksMappings) { + const organizationIdFromSurvey = await getOrganizationIdFromSurveyId(formbricksMappings.surveyId); + if (organizationIdFromSurvey !== organizationId) { + throw new AuthorizationError("You are not authorized to access this survey"); + } + + mappingsInput = await resolveFormbricksMappingsInput( + formbricksMappings.surveyId, + formbricksMappings.elementIds + ); + } else if (fieldMappings?.length) { + mappingsInput = { type: "field", mappings: fieldMappings }; + } + + return createConnectorWithMappings( + parsedInput.environmentId, + parsedInput.connectorInput, + mappingsInput + ); + } + ); + +const ZUpdateConnectorWithMappingsAction = z.object({ + connectorId: ZId, + environmentId: ZId, + connectorInput: ZConnectorUpdateInput, + formbricksMappings: z + .object({ + surveyId: ZId, + elementIds: z.array(z.string()).min(1), + }) + .optional(), + fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(), +}); + +export const updateConnectorWithMappingsAction = authenticatedActionClient + .schema(ZUpdateConnectorWithMappingsAction) + .action( + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: z.infer; + }): Promise => { + const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId); + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + minPermission: "readWrite", + projectId: await getProjectIdFromConnectorId(parsedInput.connectorId), + }, + ], + }); + + let mappingsInput: TMappingsInput | undefined; + + if (parsedInput.formbricksMappings) { + const organizationIdFromSurvey = await getOrganizationIdFromSurveyId( + parsedInput.formbricksMappings.surveyId + ); + if (organizationIdFromSurvey !== organizationId) { + throw new AuthorizationError("You are not authorized to access this survey"); + } + + mappingsInput = await resolveFormbricksMappingsInput( + parsedInput.formbricksMappings.surveyId, + parsedInput.formbricksMappings.elementIds + ); + } else if (parsedInput.fieldMappings && parsedInput.fieldMappings.length > 0) { + mappingsInput = { type: "field", mappings: parsedInput.fieldMappings }; + } + + return updateConnectorWithMappings( + parsedInput.connectorId, + parsedInput.environmentId, + parsedInput.connectorInput, + mappingsInput + ); + } + ); diff --git a/apps/web/lib/connector/hub-client.ts b/apps/web/lib/connector/hub-client.ts new file mode 100644 index 0000000000..e2c1c0405a --- /dev/null +++ b/apps/web/lib/connector/hub-client.ts @@ -0,0 +1,317 @@ +import "server-only"; +import { logger } from "@formbricks/logger"; +import { HUB_API_KEY, HUB_API_URL } from "@/lib/constants"; + +// Hub field types (from OpenAPI spec) +export type THubFieldType = + | "text" + | "categorical" + | "nps" + | "csat" + | "ces" + | "rating" + | "number" + | "boolean" + | "date"; + +// Create FeedbackRecord input +export interface TCreateFeedbackRecordInput { + collected_at?: string; + source_type: string; + field_id: string; + field_type: THubFieldType; + field_label?: string; + field_group_id?: string; + field_group_label?: string; + tenant_id?: string; + source_id?: string; + source_name?: string; + value_text?: string; + value_number?: number; + value_boolean?: boolean; + value_date?: string; + metadata?: Record; + language?: string; + user_identifier?: string; +} + +// FeedbackRecord data (response from Hub) +export interface TFeedbackRecordData { + id: string; + collected_at: string; + created_at: string; + updated_at: string; + source_type: string; + field_id: string; + field_type: THubFieldType; + field_label?: string; + field_group_id?: string; + field_group_label?: string; + tenant_id?: string; + source_id?: string; + source_name?: string; + value_text?: string; + value_number?: number; + value_boolean?: boolean; + value_date?: string; + metadata?: Record; + language?: string; + user_identifier?: string; +} + +// List FeedbackRecords response +export interface TListFeedbackRecordsResponse { + data: TFeedbackRecordData[]; + total: number; + limit: number; + offset: number; +} + +// Update FeedbackRecord input +export interface TUpdateFeedbackRecordInput { + value_text?: string; + value_number?: number; + value_boolean?: boolean; + value_date?: string; + metadata?: Record; + language?: string; + user_identifier?: string; +} + +// List FeedbackRecords filters +export interface TListFeedbackRecordsFilters { + tenant_id?: string; + source_type?: string; + source_id?: string; + field_id?: string; + field_group_id?: string; + field_type?: THubFieldType; + user_identifier?: string; + since?: string; + until?: string; + limit?: number; + offset?: number; +} + +// Error response from Hub +export interface THubErrorResponse { + type?: string; + title: string; + status: number; + detail: string; + instance?: string; + errors?: Array<{ + location?: string; + message?: string; + value?: unknown; + }>; +} + +// Hub API Error class +export class HubApiError extends Error { + status: number; + detail: string; + errors?: THubErrorResponse["errors"]; + + constructor(response: THubErrorResponse) { + super(response.detail || response.title); + this.name = "HubApiError"; + this.status = response.status; + this.detail = response.detail; + this.errors = response.errors; + } +} + +// Make authenticated request to Hub API +async function hubFetch( + path: string, + options: RequestInit = {} +): Promise<{ data: T | null; error: HubApiError | null }> { + const url = `${HUB_API_URL}${path}`; + + const headers: HeadersInit = { + "Content-Type": "application/json", + ...(HUB_API_KEY && { Authorization: `Bearer ${HUB_API_KEY}` }), + ...options.headers, + }; + + try { + const response = await fetch(url, { + ...options, + headers, + }); + + // Handle no content response (e.g., DELETE) + if (response.status === 204) { + return { data: null, error: null }; + } + + const contentType = response.headers.get("content-type"); + + if (!response.ok) { + // Try to parse error response + if (contentType?.includes("application/problem+json") || contentType?.includes("application/json")) { + const errorBody = (await response.json()) as THubErrorResponse; + return { data: null, error: new HubApiError(errorBody) }; + } + + // Fallback for non-JSON errors + const errorText = await response.text(); + return { + data: null, + error: new HubApiError({ + title: "Error", + status: response.status, + detail: errorText || `HTTP ${response.status}`, + }), + }; + } + + // Parse successful response + if (contentType?.includes("application/json")) { + const data = (await response.json()) as T; + return { data, error: null }; + } + + return { data: null, error: null }; + } catch (error) { + logger.error( + { url, error: error instanceof Error ? error.message : "Unknown error" }, + "Hub API request failed" + ); + return { + data: null, + error: new HubApiError({ + title: "Network Error", + status: 0, + detail: error instanceof Error ? error.message : "Failed to connect to Hub API", + }), + }; + } +} + +/** + * Create a new FeedbackRecord in the Hub + */ +export async function createFeedbackRecord( + input: TCreateFeedbackRecordInput +): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> { + return hubFetch("/v1/feedback-records", { + method: "POST", + body: JSON.stringify(input), + }); +} + +/** + * Create multiple FeedbackRecords in the Hub (batch) + */ +export async function createFeedbackRecordsBatch( + inputs: TCreateFeedbackRecordInput[] +): Promise<{ results: Array<{ data: TFeedbackRecordData | null; error: HubApiError | null }> }> { + // Hub doesn't have a batch endpoint, so we'll do parallel requests + // In production, you might want to implement rate limiting or chunking + const results = await Promise.all(inputs.map((input) => createFeedbackRecord(input))); + return { results }; +} + +/** + * List FeedbackRecords from the Hub with optional filters + */ +export async function listFeedbackRecords( + filters: TListFeedbackRecordsFilters = {} +): Promise<{ data: TListFeedbackRecordsResponse | null; error: HubApiError | null }> { + const searchParams = new URLSearchParams(); + + if (filters.tenant_id) searchParams.set("tenant_id", filters.tenant_id); + if (filters.source_type) searchParams.set("source_type", filters.source_type); + if (filters.source_id) searchParams.set("source_id", filters.source_id); + if (filters.field_id) searchParams.set("field_id", filters.field_id); + if (filters.field_group_id) searchParams.set("field_group_id", filters.field_group_id); + if (filters.field_type) searchParams.set("field_type", filters.field_type); + if (filters.user_identifier) searchParams.set("user_identifier", filters.user_identifier); + if (filters.since) searchParams.set("since", filters.since); + if (filters.until) searchParams.set("until", filters.until); + if (filters.limit !== undefined) searchParams.set("limit", String(filters.limit)); + if (filters.offset !== undefined) searchParams.set("offset", String(filters.offset)); + + const queryString = searchParams.toString(); + const path = queryString ? `/v1/feedback-records?${queryString}` : "/v1/feedback-records"; + + return hubFetch(path, { method: "GET" }); +} + +/** + * Get a single FeedbackRecord from the Hub by ID + */ +export async function getFeedbackRecord( + id: string +): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> { + return hubFetch(`/v1/feedback-records/${id}`, { method: "GET" }); +} + +/** + * Update a FeedbackRecord in the Hub + */ +export async function updateFeedbackRecord( + id: string, + input: TUpdateFeedbackRecordInput +): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> { + return hubFetch(`/v1/feedback-records/${id}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} + +/** + * Delete a FeedbackRecord from the Hub + */ +export async function deleteFeedbackRecord(id: string): Promise<{ error: HubApiError | null }> { + const result = await hubFetch(`/v1/feedback-records/${id}`, { method: "DELETE" }); + return { error: result.error }; +} + +/** + * Bulk delete FeedbackRecords by user identifier (GDPR compliance) + */ +export async function bulkDeleteFeedbackRecordsByUser( + userIdentifier: string, + tenantId?: string +): Promise<{ data: { deleted_count: number; message: string } | null; error: HubApiError | null }> { + const searchParams = new URLSearchParams(); + searchParams.set("user_identifier", userIdentifier); + if (tenantId) searchParams.set("tenant_id", tenantId); + + return hubFetch<{ deleted_count: number; message: string }>( + `/v1/feedback-records?${searchParams.toString()}`, + { method: "DELETE" } + ); +} + +/** + * Check Hub API health + */ +export async function checkHubHealth(): Promise<{ healthy: boolean; error: HubApiError | null }> { + try { + const response = await fetch(`${HUB_API_URL}/health`); + if (response.ok) { + return { healthy: true, error: null }; + } + return { + healthy: false, + error: new HubApiError({ + title: "Health Check Failed", + status: response.status, + detail: "Hub API health check failed", + }), + }; + } catch (error) { + return { + healthy: false, + error: new HubApiError({ + title: "Network Error", + status: 0, + detail: error instanceof Error ? error.message : "Failed to connect to Hub API", + }), + }; + } +} diff --git a/apps/web/lib/connector/pipeline-handler.ts b/apps/web/lib/connector/pipeline-handler.ts new file mode 100644 index 0000000000..1aefa11258 --- /dev/null +++ b/apps/web/lib/connector/pipeline-handler.ts @@ -0,0 +1,144 @@ +import "server-only"; +import { logger } from "@formbricks/logger"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { createFeedbackRecordsBatch } from "./hub-client"; +import { getConnectorsBySurveyId, updateConnector } from "./service"; +import { transformResponseToFeedbackRecords } from "./transform"; + +/** + * Handle connector pipeline for a survey response + * + * This function is called from the pipeline when a response is created/finished. + * It looks up active connectors for the survey and sends the response data to the Hub. + * + * @param response - The survey response + * @param survey - The survey + * @param environmentId - The environment ID (used as tenant_id) + */ +export const handleConnectorPipeline = async ( + response: TResponse, + survey: TSurvey, + environmentId: string +): Promise => { + try { + // Get all active Formbricks connectors for this survey + const connectors = await getConnectorsBySurveyId(survey.id); + + if (connectors.length === 0) { + // No connectors configured for this survey + return; + } + + // Process each connector + for (const connector of connectors) { + try { + // Transform response to FeedbackRecords using the connector's mappings + const feedbackRecords = transformResponseToFeedbackRecords( + response, + survey, + connector.formbricksMappings, + environmentId // Use environment ID as tenant_id + ); + + if (feedbackRecords.length === 0) { + // No mapped elements had values in this response + continue; + } + + // Send to Hub API + const { results } = await createFeedbackRecordsBatch(feedbackRecords); + + // Count successes and failures + const successes = results.filter((r) => r.data !== null).length; + const failures = results.filter((r) => r.error !== null).length; + + if (failures > 0) { + logger.warn( + { + connectorId: connector.id, + surveyId: survey.id, + responseId: response.id, + successes, + failures, + }, + `Connector pipeline: ${failures}/${feedbackRecords.length} FeedbackRecords failed to send` + ); + + // Log the specific errors + results.forEach((result, index) => { + if (result.error) { + logger.error( + { + connectorId: connector.id, + feedbackRecordIndex: index, + error: { + status: result.error.status, + message: result.error.message, + detail: result.error.detail, + }, + }, + "Failed to create FeedbackRecord in Hub" + ); + } + }); + + if (successes === 0) { + await updateConnector(connector.id, environmentId, { + status: "error", + errorMessage: `Failed to send FeedbackRecords to Hub: ${results[0].error?.message || "Unknown error"}`, + }); + } else { + await updateConnector(connector.id, environmentId, { + status: "active", + errorMessage: `Partial failure: ${successes}/${feedbackRecords.length} records sent`, + lastSyncAt: new Date(), + }); + } + } else { + logger.info( + { + connectorId: connector.id, + surveyId: survey.id, + responseId: response.id, + feedbackRecordsCreated: successes, + }, + `Connector pipeline: Successfully sent ${successes} FeedbackRecords to Hub` + ); + + await updateConnector(connector.id, environmentId, { + status: "active", + errorMessage: null, + lastSyncAt: new Date(), + }); + } + } catch (error) { + logger.error( + { + connectorId: connector.id, + surveyId: survey.id, + responseId: response.id, + error: error instanceof Error ? error.message : "Unknown error", + }, + "Connector pipeline: Failed to process connector" + ); + + // Update connector with error + await updateConnector(connector.id, environmentId, { + status: "error", + errorMessage: error instanceof Error ? error.message : "Unknown error", + }); + } + } + } catch (error) { + // Log but don't throw - we don't want to break the main pipeline + logger.error( + { + surveyId: survey.id, + responseId: response.id, + error: error instanceof Error ? error.message : "Unknown error", + }, + "Connector pipeline: Failed to handle connectors" + ); + } +}; diff --git a/apps/web/lib/connector/service.test.ts b/apps/web/lib/connector/service.test.ts new file mode 100644 index 0000000000..add5735080 --- /dev/null +++ b/apps/web/lib/connector/service.test.ts @@ -0,0 +1,519 @@ +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { + createConnectorWithMappings, + deleteConnector, + getConnectorsBySurveyId, + getConnectorsWithMappings, + updateConnector, + updateConnectorWithMappings, +} from "./service"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + connector: { + findMany: vi.fn(), + findUniqueOrThrow: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + connectorFormbricksMapping: { + create: vi.fn(), + deleteMany: vi.fn(), + }, + connectorFieldMapping: { + create: vi.fn(), + deleteMany: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); + +const ENV_ID = "clxxxxxxxxxxxxxxxx001"; +const CONNECTOR_ID = "clxxxxxxxxxxxxxxxx002"; +const SURVEY_ID = "clxxxxxxxxxxxxxxxx003"; +const NOW = new Date("2026-02-24T10:00:00.000Z"); + +const mockConnector = { + id: CONNECTOR_ID, + createdAt: NOW, + updatedAt: NOW, + name: "Test Connector", + type: "formbricks" as const, + status: "active" as const, + environmentId: ENV_ID, + lastSyncAt: null, + errorMessage: null, +}; + +const mockConnectorWithMappings = { + ...mockConnector, + formbricksMappings: [ + { + id: "mapping-1", + createdAt: NOW, + connectorId: CONNECTOR_ID, + environmentId: ENV_ID, + surveyId: SURVEY_ID, + elementId: "el-1", + hubFieldType: "text", + customFieldLabel: null, + }, + ], + fieldMappings: [], +}; + +describe("getConnectorsWithMappings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns connectors for the given environment", async () => { + vi.mocked(prisma.connector.findMany).mockResolvedValue([mockConnectorWithMappings] as never); + + const result = await getConnectorsWithMappings(ENV_ID); + + expect(prisma.connector.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { environmentId: ENV_ID }, + orderBy: { createdAt: "desc" }, + }) + ); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(CONNECTOR_ID); + }); + + test("applies pagination when page is provided", async () => { + vi.mocked(prisma.connector.findMany).mockResolvedValue([] as never); + + await getConnectorsWithMappings(ENV_ID, 2); + + expect(prisma.connector.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: expect.any(Number), + skip: expect.any(Number), + }) + ); + }); + + test("returns empty array when no connectors exist", async () => { + vi.mocked(prisma.connector.findMany).mockResolvedValue([] as never); + + const result = await getConnectorsWithMappings(ENV_ID); + expect(result).toEqual([]); + }); + + test("throws DatabaseError on Prisma error", async () => { + vi.mocked(prisma.connector.findMany).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("connection error", { + code: "P1001", + clientVersion: "5.0.0", + }) + ); + + await expect(getConnectorsWithMappings(ENV_ID)).rejects.toThrow(DatabaseError); + }); +}); + +describe("getConnectorsBySurveyId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns active formbricks connectors linked to the survey", async () => { + vi.mocked(prisma.connector.findMany).mockResolvedValue([mockConnectorWithMappings] as never); + + const result = await getConnectorsBySurveyId(SURVEY_ID); + + expect(prisma.connector.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + type: "formbricks", + status: "active", + formbricksMappings: { some: { surveyId: SURVEY_ID } }, + }, + }) + ); + expect(result).toHaveLength(1); + }); + + test("returns empty when no connectors match", async () => { + vi.mocked(prisma.connector.findMany).mockResolvedValue([] as never); + + const result = await getConnectorsBySurveyId(SURVEY_ID); + expect(result).toEqual([]); + }); + + test("throws DatabaseError on Prisma error", async () => { + vi.mocked(prisma.connector.findMany).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P1001", + clientVersion: "5.0.0", + }) + ); + + await expect(getConnectorsBySurveyId(SURVEY_ID)).rejects.toThrow(DatabaseError); + }); +}); + +describe("updateConnector", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("updates connector name and returns the result", async () => { + const updated = { ...mockConnector, name: "Renamed" }; + vi.mocked(prisma.connector.update).mockResolvedValue(updated as never); + + const result = await updateConnector(CONNECTOR_ID, ENV_ID, { name: "Renamed" }); + + expect(prisma.connector.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: CONNECTOR_ID, environmentId: ENV_ID }, + data: expect.objectContaining({ name: "Renamed" }), + }) + ); + expect(result.name).toBe("Renamed"); + }); + + test("updates connector status", async () => { + const updated = { ...mockConnector, status: "paused" }; + vi.mocked(prisma.connector.update).mockResolvedValue(updated as never); + + const result = await updateConnector(CONNECTOR_ID, ENV_ID, { status: "paused" }); + expect(result.status).toBe("paused"); + }); + + test("throws ResourceNotFoundError when connector does not exist", async () => { + vi.mocked(prisma.connector.update).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Not found", { + code: "P2015", + clientVersion: "5.0.0", + }) + ); + + await expect(updateConnector(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(ResourceNotFoundError); + }); + + test("throws DatabaseError on generic Prisma error", async () => { + vi.mocked(prisma.connector.update).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P1001", + clientVersion: "5.0.0", + }) + ); + + await expect(updateConnector(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(DatabaseError); + }); + + test("rethrows non-Prisma errors", async () => { + vi.mocked(prisma.connector.update).mockRejectedValue(new Error("unexpected")); + + await expect(updateConnector(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow("unexpected"); + }); +}); + +describe("deleteConnector", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("deletes the connector and returns it", async () => { + vi.mocked(prisma.connector.delete).mockResolvedValue(mockConnector as never); + + const result = await deleteConnector(CONNECTOR_ID, ENV_ID); + + expect(prisma.connector.delete).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: CONNECTOR_ID, environmentId: ENV_ID }, + }) + ); + expect(result.id).toBe(CONNECTOR_ID); + }); + + test("throws ResourceNotFoundError when connector does not exist", async () => { + vi.mocked(prisma.connector.delete).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Not found", { + code: "P2015", + clientVersion: "5.0.0", + }) + ); + + await expect(deleteConnector(CONNECTOR_ID, ENV_ID)).rejects.toThrow(ResourceNotFoundError); + }); + + test("throws DatabaseError on generic Prisma error", async () => { + vi.mocked(prisma.connector.delete).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P1001", + clientVersion: "5.0.0", + }) + ); + + await expect(deleteConnector(CONNECTOR_ID, ENV_ID)).rejects.toThrow(DatabaseError); + }); +}); + +describe("createConnectorWithMappings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const setupTransaction = () => { + const txMethods = { + connector: { + create: vi.fn(), + findUniqueOrThrow: vi.fn(), + }, + connectorFormbricksMapping: { + create: vi.fn(), + }, + connectorFieldMapping: { + create: vi.fn(), + }, + }; + + vi.mocked(prisma.$transaction).mockImplementation(async (fn) => { + return (fn as (tx: typeof txMethods) => Promise)(txMethods); + }); + + return txMethods; + }; + + test("creates connector without mappings", async () => { + const tx = setupTransaction(); + tx.connector.create.mockResolvedValue({ id: CONNECTOR_ID, environmentId: ENV_ID }); + tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappings); + + const result = await createConnectorWithMappings(ENV_ID, { name: "New", type: "formbricks" }); + + expect(tx.connector.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: { name: "New", type: "formbricks", environmentId: ENV_ID }, + }) + ); + expect(tx.connectorFormbricksMapping.create).not.toHaveBeenCalled(); + expect(tx.connectorFieldMapping.create).not.toHaveBeenCalled(); + expect(result).toEqual(mockConnectorWithMappings); + }); + + test("creates connector with formbricks mappings", async () => { + const tx = setupTransaction(); + tx.connector.create.mockResolvedValue({ id: CONNECTOR_ID, environmentId: ENV_ID }); + tx.connectorFormbricksMapping.create.mockResolvedValue({}); + tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappings); + + await createConnectorWithMappings( + ENV_ID, + { name: "FB", type: "formbricks" }, + { + type: "formbricks", + mappings: [ + { surveyId: SURVEY_ID, elementId: "el-1", hubFieldType: "text" }, + { surveyId: SURVEY_ID, elementId: "el-2", hubFieldType: "nps" }, + ], + } + ); + + expect(tx.connectorFormbricksMapping.create).toHaveBeenCalledTimes(2); + expect(tx.connectorFormbricksMapping.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + connectorId: CONNECTOR_ID, + environmentId: ENV_ID, + surveyId: SURVEY_ID, + elementId: "el-1", + hubFieldType: "text", + }), + }) + ); + }); + + test("creates connector with field mappings", async () => { + const tx = setupTransaction(); + tx.connector.create.mockResolvedValue({ id: CONNECTOR_ID, environmentId: ENV_ID }); + tx.connectorFieldMapping.create.mockResolvedValue({}); + tx.connector.findUniqueOrThrow.mockResolvedValue({ + ...mockConnector, + formbricksMappings: [], + fieldMappings: [], + }); + + await createConnectorWithMappings( + ENV_ID, + { name: "CSV", type: "csv" }, + { + type: "field", + mappings: [{ sourceFieldId: "col-1", targetFieldId: "value_text" }], + } + ); + + expect(tx.connectorFieldMapping.create).toHaveBeenCalledTimes(1); + expect(tx.connectorFieldMapping.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + connectorId: CONNECTOR_ID, + environmentId: ENV_ID, + sourceFieldId: "col-1", + targetFieldId: "value_text", + }), + }) + ); + }); + + test("throws InvalidInputError on unique constraint violation", async () => { + vi.mocked(prisma.$transaction).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint", { + code: "P2002", + clientVersion: "5.0.0", + }) + ); + + await expect(createConnectorWithMappings(ENV_ID, { name: "Dup", type: "formbricks" })).rejects.toThrow( + InvalidInputError + ); + }); + + test("throws DatabaseError on generic Prisma error", async () => { + vi.mocked(prisma.$transaction).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P1001", + clientVersion: "5.0.0", + }) + ); + + await expect(createConnectorWithMappings(ENV_ID, { name: "Fail", type: "csv" })).rejects.toThrow( + DatabaseError + ); + }); +}); + +describe("updateConnectorWithMappings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const setupTransaction = () => { + const txMethods = { + connector: { + update: vi.fn(), + findUniqueOrThrow: vi.fn(), + }, + connectorFormbricksMapping: { + create: vi.fn(), + deleteMany: vi.fn(), + }, + connectorFieldMapping: { + create: vi.fn(), + deleteMany: vi.fn(), + }, + }; + + vi.mocked(prisma.$transaction).mockImplementation(async (fn) => { + return (fn as (tx: typeof txMethods) => Promise)(txMethods); + }); + + return txMethods; + }; + + test("updates connector name without changing mappings", async () => { + const tx = setupTransaction(); + tx.connector.update.mockResolvedValue(undefined); + tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappings); + + const result = await updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "Updated" }); + + expect(tx.connector.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: CONNECTOR_ID, environmentId: ENV_ID }, + data: expect.objectContaining({ name: "Updated" }), + }) + ); + expect(tx.connectorFormbricksMapping.deleteMany).not.toHaveBeenCalled(); + expect(tx.connectorFieldMapping.deleteMany).not.toHaveBeenCalled(); + expect(result).toEqual(mockConnectorWithMappings); + }); + + test("replaces formbricks mappings when provided", async () => { + const tx = setupTransaction(); + tx.connector.update.mockResolvedValue(undefined); + tx.connectorFormbricksMapping.deleteMany.mockResolvedValue({ count: 1 }); + tx.connectorFormbricksMapping.create.mockResolvedValue({}); + tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappings); + + await updateConnectorWithMappings( + CONNECTOR_ID, + ENV_ID, + { name: "Updated" }, + { + type: "formbricks", + mappings: [{ surveyId: SURVEY_ID, elementId: "el-new", hubFieldType: "nps" }], + } + ); + + expect(tx.connectorFormbricksMapping.deleteMany).toHaveBeenCalledWith({ + where: { connectorId: CONNECTOR_ID, environmentId: ENV_ID }, + }); + expect(tx.connectorFormbricksMapping.create).toHaveBeenCalledTimes(1); + }); + + test("replaces field mappings when provided", async () => { + const tx = setupTransaction(); + tx.connector.update.mockResolvedValue(undefined); + tx.connectorFieldMapping.deleteMany.mockResolvedValue({ count: 1 }); + tx.connectorFieldMapping.create.mockResolvedValue({}); + tx.connector.findUniqueOrThrow.mockResolvedValue({ + ...mockConnector, + formbricksMappings: [], + fieldMappings: [], + }); + + await updateConnectorWithMappings( + CONNECTOR_ID, + ENV_ID, + { name: "CSV Updated" }, + { + type: "field", + mappings: [{ sourceFieldId: "col-x", targetFieldId: "value_number" }], + } + ); + + expect(tx.connectorFieldMapping.deleteMany).toHaveBeenCalledWith({ + where: { connectorId: CONNECTOR_ID, environmentId: ENV_ID }, + }); + expect(tx.connectorFieldMapping.create).toHaveBeenCalledTimes(1); + }); + + test("throws ResourceNotFoundError when connector does not exist", async () => { + vi.mocked(prisma.$transaction).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Not found", { + code: "P2015", + clientVersion: "5.0.0", + }) + ); + + await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow( + ResourceNotFoundError + ); + }); + + test("throws DatabaseError on generic Prisma error", async () => { + vi.mocked(prisma.$transaction).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P1001", + clientVersion: "5.0.0", + }) + ); + + await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow( + DatabaseError + ); + }); +}); diff --git a/apps/web/lib/connector/service.ts b/apps/web/lib/connector/service.ts new file mode 100644 index 0000000000..0bdfdd680f --- /dev/null +++ b/apps/web/lib/connector/service.ts @@ -0,0 +1,336 @@ +import "server-only"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { ZId, ZOptionalNumber } from "@formbricks/types/common"; +import { + TConnector, + TConnectorCreateInput, + TConnectorFieldMappingCreateInput, + TConnectorFormbricksMappingCreateInput, + TConnectorUpdateInput, + TConnectorWithMappings, + ZConnectorCreateInput, + ZConnectorUpdateInput, +} from "@formbricks/types/connector"; +import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { ITEMS_PER_PAGE } from "../constants"; +import { validateInputs } from "../utils/validate"; + +const selectConnectorWithMappings = { + id: true, + createdAt: true, + updatedAt: true, + name: true, + type: true, + status: true, + environmentId: true, + lastSyncAt: true, + errorMessage: true, + formbricksMappings: { + select: { + id: true, + createdAt: true, + connectorId: true, + environmentId: true, + surveyId: true, + elementId: true, + hubFieldType: true, + customFieldLabel: true, + }, + }, + fieldMappings: { + select: { + id: true, + createdAt: true, + connectorId: true, + environmentId: true, + sourceFieldId: true, + targetFieldId: true, + staticValue: true, + }, + }, +} satisfies Prisma.ConnectorSelect; + +const selectConnector = { + id: true, + createdAt: true, + updatedAt: true, + name: true, + type: true, + status: true, + environmentId: true, + lastSyncAt: true, + errorMessage: true, +} satisfies Prisma.ConnectorSelect; + +export const getConnectorsWithMappings = reactCache( + async (environmentId: string, page?: number): Promise => { + validateInputs([environmentId, ZId], [page, ZOptionalNumber]); + + try { + const connectors = await prisma.connector.findMany({ + where: { + environmentId, + }, + select: selectConnectorWithMappings, + orderBy: { + createdAt: "desc", + }, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + }); + + return connectors as TConnectorWithMappings[]; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + } +); + +export const getConnectorsBySurveyId = reactCache( + async (surveyId: string): Promise => { + validateInputs([surveyId, ZId]); + + try { + const connectors = await prisma.connector.findMany({ + where: { + type: "formbricks", + status: "active", + formbricksMappings: { + some: { + surveyId, + }, + }, + }, + select: selectConnectorWithMappings, + }); + + return connectors as TConnectorWithMappings[]; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + } +); + +export const updateConnector = async ( + connectorId: string, + environmentId: string, + data: TConnectorUpdateInput +): Promise => { + validateInputs([connectorId, ZId], [data, ZConnectorUpdateInput], [environmentId, ZId]); + + try { + const connector = await prisma.connector.update({ + where: { + id: connectorId, + environmentId, + }, + data: { + name: data.name, + status: data.status, + errorMessage: data.errorMessage, + lastSyncAt: data.lastSyncAt, + }, + select: selectConnector, + }); + + return connector as TConnector; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === PrismaErrorType.RecordDoesNotExist) { + throw new ResourceNotFoundError("Connector", connectorId); + } + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const deleteConnector = async (connectorId: string, environmentId: string): Promise => { + validateInputs([connectorId, ZId], [environmentId, ZId]); + + try { + const connector = await prisma.connector.delete({ + where: { + id: connectorId, + environmentId, + }, + select: selectConnector, + }); + + return connector as TConnector; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === PrismaErrorType.RecordDoesNotExist) { + throw new ResourceNotFoundError("Connector", connectorId); + } + throw new DatabaseError(error.message); + } + throw error; + } +}; + +// -- Composite functions -- + +export type TFormbricksMappingsInput = { + type: "formbricks"; + mappings: TConnectorFormbricksMappingCreateInput[]; +}; + +export type TFieldMappingsInput = { + type: "field"; + mappings: TConnectorFieldMappingCreateInput[]; +}; + +export type TMappingsInput = TFormbricksMappingsInput | TFieldMappingsInput; + +export const createConnectorWithMappings = async ( + environmentId: string, + data: TConnectorCreateInput, + mappingsInput?: TMappingsInput +): Promise => { + validateInputs([environmentId, ZId], [data, ZConnectorCreateInput]); + + try { + const result = await prisma.$transaction(async (tx) => { + const connector = await tx.connector.create({ + data: { + name: data.name, + type: data.type, + environmentId, + }, + }); + + if (mappingsInput?.type === "formbricks") { + await Promise.all( + mappingsInput.mappings.map((mapping) => + tx.connectorFormbricksMapping.create({ + data: { + connectorId: connector.id, + environmentId, + surveyId: mapping.surveyId, + elementId: mapping.elementId, + hubFieldType: mapping.hubFieldType, + customFieldLabel: mapping.customFieldLabel, + }, + }) + ) + ); + } else if (mappingsInput?.type === "field") { + await Promise.all( + mappingsInput.mappings.map((mapping) => + tx.connectorFieldMapping.create({ + data: { + connectorId: connector.id, + environmentId, + sourceFieldId: mapping.sourceFieldId, + targetFieldId: mapping.targetFieldId, + staticValue: mapping.staticValue, + }, + }) + ) + ); + } + + return tx.connector.findUniqueOrThrow({ + where: { id: connector.id }, + select: selectConnectorWithMappings, + }); + }); + + return result as TConnectorWithMappings; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === PrismaErrorType.UniqueConstraintViolation) { + throw new InvalidInputError(`Connector with name ${data.name} already exists`); + } + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const updateConnectorWithMappings = async ( + connectorId: string, + environmentId: string, + data: TConnectorUpdateInput, + mappingsInput?: TMappingsInput +): Promise => { + validateInputs([connectorId, ZId], [data, ZConnectorUpdateInput], [environmentId, ZId]); + + try { + const result = await prisma.$transaction(async (tx) => { + await tx.connector.update({ + where: { id: connectorId, environmentId }, + data: { + name: data.name, + status: data.status, + errorMessage: data.errorMessage, + lastSyncAt: data.lastSyncAt, + }, + }); + + if (mappingsInput?.type === "formbricks") { + await tx.connectorFormbricksMapping.deleteMany({ + where: { connectorId, environmentId }, + }); + + await Promise.all( + mappingsInput.mappings.map((mapping) => + tx.connectorFormbricksMapping.create({ + data: { + connectorId, + environmentId, + surveyId: mapping.surveyId, + elementId: mapping.elementId, + hubFieldType: mapping.hubFieldType, + customFieldLabel: mapping.customFieldLabel, + }, + }) + ) + ); + } else if (mappingsInput?.type === "field") { + await tx.connectorFieldMapping.deleteMany({ + where: { connectorId, environmentId }, + }); + + await Promise.all( + mappingsInput.mappings.map((mapping) => + tx.connectorFieldMapping.create({ + data: { + connectorId, + environmentId, + sourceFieldId: mapping.sourceFieldId, + targetFieldId: mapping.targetFieldId, + staticValue: mapping.staticValue, + }, + }) + ) + ); + } + + return tx.connector.findUniqueOrThrow({ + where: { id: connectorId }, + select: selectConnectorWithMappings, + }); + }); + + return result as TConnectorWithMappings; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === PrismaErrorType.RecordDoesNotExist) { + throw new ResourceNotFoundError("Connector", connectorId); + } + throw new DatabaseError(error.message); + } + throw error; + } +}; diff --git a/apps/web/lib/connector/transform.test.ts b/apps/web/lib/connector/transform.test.ts new file mode 100644 index 0000000000..8b83814275 --- /dev/null +++ b/apps/web/lib/connector/transform.test.ts @@ -0,0 +1,316 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { TConnectorFormbricksMapping } from "@formbricks/types/connector"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { transformResponseToFeedbackRecords } from "./transform"; + +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: (_val: Record, _lang: string) => _val?.default ?? "", +})); + +vi.mock("@formbricks/types/surveys/validation", () => ({ + getTextContent: (str: string) => str, +})); + +vi.mock("@/lib/survey/utils", () => ({ + getElementsFromBlocks: (blocks: Array<{ elements: unknown[] }>) => + blocks.flatMap((block) => block.elements), +})); + +const NOW = new Date("2026-02-24T10:00:00.000Z"); + +const mockSurvey = { + id: "survey-1", + name: "Product Feedback", + blocks: [ + { + elements: [ + { id: "el-text", type: "openText", headline: { default: "How can we improve?" } }, + { id: "el-nps", type: "nps", headline: { default: "How likely to recommend?" } }, + { id: "el-rating", type: "rating", headline: { default: "Rate your experience" } }, + { id: "el-date", type: "date", headline: { default: "When did you visit?" } }, + { id: "el-bool", type: "consent", headline: { default: "Do you agree?" } }, + { + id: "el-multi", + type: "multipleChoiceMulti", + headline: { default: "Select features" }, + }, + ], + }, + ], +} as unknown as TSurvey; + +const mockResponse = { + id: "resp-1", + createdAt: NOW, + data: { + "el-text": "Great product!", + "el-nps": 9, + "el-rating": 4, + "el-date": "2026-01-15", + "el-bool": "true", + "el-multi": ["feat-a", "feat-b"], + }, + language: "en", + contact: { userId: "user-42" }, +} as unknown as TResponse; + +const createMapping = ( + overrides: Partial & + Pick +): TConnectorFormbricksMapping => ({ + id: `mapping-${overrides.elementId}`, + createdAt: NOW, + connectorId: "conn-1", + environmentId: "env-1", + surveyId: "survey-1", + customFieldLabel: null, + ...overrides, +}); + +const allMappings: TConnectorFormbricksMapping[] = [ + createMapping({ elementId: "el-text", hubFieldType: "text" }), + createMapping({ elementId: "el-nps", hubFieldType: "nps" }), + createMapping({ elementId: "el-rating", hubFieldType: "rating" }), + createMapping({ elementId: "el-date", hubFieldType: "date" }), + createMapping({ elementId: "el-bool", hubFieldType: "boolean" }), + createMapping({ elementId: "el-multi", hubFieldType: "categorical" }), +]; + +describe("transformResponseToFeedbackRecords", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns empty array when response has no data", () => { + const emptyResponse = { ...mockResponse, data: null } as unknown as TResponse; + const result = transformResponseToFeedbackRecords(emptyResponse, mockSurvey, allMappings); + expect(result).toEqual([]); + }); + + test("returns empty array when no mappings match the survey", () => { + const otherSurveyMappings = allMappings.map((m) => ({ ...m, surveyId: "other-survey" })); + const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, otherSurveyMappings); + expect(result).toEqual([]); + }); + + test("skips elements with empty string values", () => { + const response = { + ...mockResponse, + data: { "el-text": "" }, + } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })]; + const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings); + expect(result).toEqual([]); + }); + + test("skips elements with undefined values", () => { + const response = { + ...mockResponse, + data: { "el-nps": 9 }, + } as unknown as TResponse; + const mappings = [ + createMapping({ elementId: "el-text", hubFieldType: "text" }), + createMapping({ elementId: "el-nps", hubFieldType: "nps" }), + ]; + const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings); + expect(result).toHaveLength(1); + expect(result[0].field_id).toBe("el-nps"); + }); + + test("transforms text field correctly", () => { + const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })]; + const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + source_type: "formbricks", + field_id: "el-text", + field_type: "text", + field_label: "How can we improve?", + source_id: "survey-1", + source_name: "Product Feedback", + value_text: "Great product!", + language: "en", + user_identifier: "user-42", + }); + }); + + test("transforms nps field correctly", () => { + const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })]; + const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings); + expect(result).toHaveLength(1); + expect(result[0].value_number).toBe(9); + expect(result[0].field_type).toBe("nps"); + }); + + test("transforms rating field correctly", () => { + const mappings = [createMapping({ elementId: "el-rating", hubFieldType: "rating" })]; + const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings); + expect(result).toHaveLength(1); + expect(result[0].value_number).toBe(4); + }); + + test("transforms date field to ISO string", () => { + const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })]; + const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings); + expect(result).toHaveLength(1); + expect(result[0].value_date).toBe(new Date("2026-01-15").toISOString()); + }); + + test("transforms boolean field correctly", () => { + const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })]; + const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings); + expect(result).toHaveLength(1); + expect(result[0].value_boolean).toBe(true); + }); + + test("transforms categorical (multi-select) field to comma-separated text", () => { + const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })]; + const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings); + expect(result).toHaveLength(1); + expect(result[0].value_text).toBe("feat-a, feat-b"); + }); + + test("uses customFieldLabel when provided", () => { + const mappings = [ + createMapping({ elementId: "el-text", hubFieldType: "text", customFieldLabel: "Custom Label" }), + ]; + const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings); + expect(result[0].field_label).toBe("Custom Label"); + }); + + test("sets collected_at from response createdAt", () => { + const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })]; + const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings); + expect(result[0].collected_at).toBe(NOW.toISOString()); + }); + + test("includes tenant_id when provided", () => { + const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })]; + const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, "tenant-abc"); + expect(result[0].tenant_id).toBe("tenant-abc"); + }); + + test("omits tenant_id when not provided", () => { + const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })]; + const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings); + expect(result[0].tenant_id).toBeUndefined(); + }); + + test("omits language when response language is 'default'", () => { + const response = { ...mockResponse, language: "default" } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })]; + const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings); + expect(result[0].language).toBeUndefined(); + }); + + test("omits user_identifier when contact has no userId", () => { + const response = { ...mockResponse, contact: null } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })]; + const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings); + expect(result[0].user_identifier).toBeUndefined(); + }); + + test("transforms all mappings in a single call", () => { + const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings); + expect(result).toHaveLength(6); + const fieldIds = result.map((r) => r.field_id); + expect(fieldIds).toEqual(["el-text", "el-nps", "el-rating", "el-date", "el-bool", "el-multi"]); + }); + + test("falls back to 'Untitled' for element with no headline", () => { + const survey = { + ...mockSurvey, + blocks: [{ elements: [{ id: "el-bare", type: "openText" }] }], + } as unknown as TSurvey; + const response = { + ...mockResponse, + data: { "el-bare": "some text" }, + } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-bare", hubFieldType: "text" })]; + const result = transformResponseToFeedbackRecords(response, survey, mappings); + expect(result[0].field_label).toBe("Untitled"); + }); + + describe("convertValueToHubFields edge cases", () => { + test("parses numeric string for nps field", () => { + const response = { + ...mockResponse, + data: { "el-nps": "7" }, + } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })]; + const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings); + expect(result[0].value_number).toBe(7); + }); + + test("returns empty fields for non-parseable numeric string", () => { + const response = { + ...mockResponse, + data: { "el-nps": "not-a-number" }, + } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })]; + const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings); + expect(result[0].value_number).toBeUndefined(); + }); + + test("handles object value for text field", () => { + const response = { + ...mockResponse, + data: { "el-text": { nested: "value" } }, + } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })]; + const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings); + expect(result[0].value_text).toBe(JSON.stringify({ nested: "value" })); + }); + + test("handles invalid date string gracefully", () => { + const response = { + ...mockResponse, + data: { "el-date": "not-a-date" }, + } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })]; + const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings); + expect(result[0].value_date).toBeUndefined(); + }); + + test("converts boolean string '1' to true", () => { + const response = { + ...mockResponse, + data: { "el-bool": "1" }, + } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })]; + const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings); + expect(result[0].value_boolean).toBe(true); + }); + + test("converts boolean string 'false' to false", () => { + const response = { + ...mockResponse, + data: { "el-bool": "false" }, + } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })]; + const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings); + expect(result[0].value_boolean).toBe(false); + }); + + test("handles array value for text field", () => { + const response = { + ...mockResponse, + data: { "el-text": ["a", "b", "c"] }, + } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })]; + const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings); + expect(result[0].value_text).toBe("a, b, c"); + }); + + test("handles single string value for categorical field", () => { + const response = { + ...mockResponse, + data: { "el-multi": "single-choice" }, + } as unknown as TResponse; + const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })]; + const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings); + expect(result[0].value_text).toBe("single-choice"); + }); + }); +}); diff --git a/apps/web/lib/connector/transform.ts b/apps/web/lib/connector/transform.ts new file mode 100644 index 0000000000..eeb208ab70 --- /dev/null +++ b/apps/web/lib/connector/transform.ts @@ -0,0 +1,132 @@ +import "server-only"; +import { TConnectorFormbricksMapping, THubFieldType } from "@formbricks/types/connector"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { getTextContent } from "@formbricks/types/surveys/validation"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { getElementsFromBlocks } from "@/lib/survey/utils"; +import { TCreateFeedbackRecordInput } from "./hub-client"; + +type TResponseValue = string | number | string[] | Record | undefined; + +type TSurveyElement = ReturnType[number]; + +const getHeadlineFromElement = (element: TSurveyElement | undefined): string => { + if (!element?.headline) return "Untitled"; + const raw = getLocalizedValue(element.headline, "default"); + return getTextContent(raw) || "Untitled"; +}; + +function extractResponseValue(responseData: TResponse["data"], elementId: string): TResponseValue { + if (!responseData || typeof responseData !== "object") return undefined; + return (responseData as Record)[elementId]; +} + +const convertValueToHubFields = ( + value: TResponseValue, + hubFieldType: THubFieldType +): Partial< + Pick +> => { + if (value === undefined || value === null) { + return {}; + } + + switch (hubFieldType) { + case "text": + if (typeof value === "string") return { value_text: value }; + if (Array.isArray(value)) return { value_text: value.join(", ") }; + if (typeof value === "object") return { value_text: JSON.stringify(value) }; + return { value_text: String(value) }; + + case "number": + case "rating": + case "nps": + case "csat": + case "ces": + if (typeof value === "number") return { value_number: value }; + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + if (!Number.isNaN(parsed)) return { value_number: parsed }; + } + return {}; + + case "boolean": + if (typeof value === "boolean") return { value_boolean: value }; + if (typeof value === "string") { + return { value_boolean: value.toLowerCase() === "true" || value === "1" }; + } + return {}; + + case "date": + if (typeof value === "string") { + const date = new Date(value); + if (!Number.isNaN(date.getTime())) return { value_date: date.toISOString() }; + } + if (value instanceof Date) return { value_date: value.toISOString() }; + return {}; + + case "categorical": + if (typeof value === "string") return { value_text: value }; + if (Array.isArray(value)) return { value_text: value.join(", ") }; + return { value_text: String(value) }; + + default: + return { value_text: typeof value === "string" ? value : String(value) }; + } +}; + +/** + * Transform a Formbricks survey response into Hub FeedbackRecord payloads. + * Called from the pipeline handler when a response is created/finished. + */ +export function transformResponseToFeedbackRecords( + response: TResponse, + survey: TSurvey, + mappings: TConnectorFormbricksMapping[], + tenantId?: string +): TCreateFeedbackRecordInput[] { + const responseData = response.data; + if (!responseData) return []; + + const surveyMappings = mappings.filter((m) => m.surveyId === survey.id); + const elements = getElementsFromBlocks(survey.blocks); + const elementMap = new Map(elements.map((el) => [el.id, el])); + const feedbackRecords: TCreateFeedbackRecordInput[] = []; + + for (const mapping of surveyMappings) { + const value = extractResponseValue(responseData, mapping.elementId); + if (value === undefined || value === null || value === "") continue; + + const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(elementMap.get(mapping.elementId)); + const valueFields = convertValueToHubFields(value, mapping.hubFieldType); + + const feedbackRecord: TCreateFeedbackRecordInput = { + collected_at: + response.createdAt instanceof Date ? response.createdAt.toISOString() : String(response.createdAt), + source_type: "formbricks", + field_id: mapping.elementId, + field_type: mapping.hubFieldType, + source_id: survey.id, + source_name: survey.name, + field_label: fieldLabel, + ...valueFields, + }; + + if (response.language && response.language !== "default") { + feedbackRecord.language = response.language; + } + + if (tenantId) { + feedbackRecord.tenant_id = tenantId; + } + + if (response.contact?.userId) { + feedbackRecord.user_identifier = response.contact.userId; + } + + feedbackRecords.push(feedbackRecord); + } + + return feedbackRecords; +} diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index 7f2e73e57c..d3b5b5e189 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -41,6 +41,9 @@ export const GITHUB_SECRET = env.GITHUB_SECRET; export const GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID; export const GOOGLE_CLIENT_SECRET = env.GOOGLE_CLIENT_SECRET; +export const HUB_API_URL = env.HUB_API_URL; +export const HUB_API_KEY = env.HUB_API_KEY; + export const AZUREAD_CLIENT_ID = env.AZUREAD_CLIENT_ID; export const AZUREAD_CLIENT_SECRET = env.AZUREAD_CLIENT_SECRET; export const AZUREAD_TENANT_ID = env.AZUREAD_TENANT_ID; diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index fd403eb742..13e812df53 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -33,6 +33,8 @@ export const env = createEnv({ GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(), HTTP_PROXY: z.string().url().optional(), HTTPS_PROXY: z.string().url().optional(), + HUB_API_URL: z.string().url().optional(), + HUB_API_KEY: z.string().optional(), IMPRINT_URL: z .string() .url() @@ -161,6 +163,8 @@ export const env = createEnv({ GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL, HTTP_PROXY: process.env.HTTP_PROXY, HTTPS_PROXY: process.env.HTTPS_PROXY, + HUB_API_URL: process.env.HUB_API_URL, + HUB_API_KEY: process.env.HUB_API_KEY, IMPRINT_URL: process.env.IMPRINT_URL, IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS, INVITE_DISABLED: process.env.INVITE_DISABLED, diff --git a/apps/web/lib/styling/constants.ts b/apps/web/lib/styling/constants.ts index ca3f2769bc..b18fcabaa9 100644 --- a/apps/web/lib/styling/constants.ts +++ b/apps/web/lib/styling/constants.ts @@ -180,8 +180,7 @@ export const deriveNewFieldsFromLegacy = (saved: Record): Recor ...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }), ...(i && !saved.optionBgColor && { optionBgColor: { light: i } }), ...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }), - ...(b && - !saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }), + ...(b && !saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }), }; }; diff --git a/apps/web/lib/utils/helper.test.ts b/apps/web/lib/utils/helper.test.ts index 19c9543566..a60f985016 100644 --- a/apps/web/lib/utils/helper.test.ts +++ b/apps/web/lib/utils/helper.test.ts @@ -9,6 +9,7 @@ import { getFormattedErrorMessage, getOrganizationIdFromActionClassId, getOrganizationIdFromApiKeyId, + getOrganizationIdFromConnectorId, getOrganizationIdFromContactId, getOrganizationIdFromEnvironmentId, getOrganizationIdFromIntegrationId, @@ -24,6 +25,7 @@ import { getOrganizationIdFromWebhookId, getProductIdFromContactId, getProjectIdFromActionClassId, + getProjectIdFromConnectorId, getProjectIdFromContactId, getProjectIdFromEnvironmentId, getProjectIdFromIntegrationId, @@ -54,6 +56,7 @@ vi.mock("@/lib/utils/services", () => ({ getLanguage: vi.fn(), getTeam: vi.fn(), getTag: vi.fn(), + getConnector: vi.fn(), })); describe("Helper Utilities", () => { @@ -382,6 +385,31 @@ describe("Helper Utilities", () => { const orgId = await getOrganizationIdFromQuotaId("quota1"); expect(orgId).toBe("org1"); }); + + test("getOrganizationIdFromConnectorId returns organization ID through environment and project", async () => { + vi.mocked(services.getConnector).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromConnectorId("connector1"); + expect(orgId).toBe("org1"); + expect(services.getConnector).toHaveBeenCalledWith("connector1"); + expect(services.getEnvironment).toHaveBeenCalledWith("env1"); + expect(services.getProject).toHaveBeenCalledWith("project1"); + }); + + test("getOrganizationIdFromConnectorId throws error when connector not found", async () => { + vi.mocked(services.getConnector).mockResolvedValueOnce(null); + + await expect(getOrganizationIdFromConnectorId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + expect(services.getConnector).toHaveBeenCalledWith("nonexistent"); + }); }); describe("Project ID retrieval functions", () => { @@ -587,6 +615,27 @@ describe("Helper Utilities", () => { const projectId = await getProjectIdFromQuotaId("quota1"); expect(projectId).toBe("project1"); }); + + test("getProjectIdFromConnectorId returns project ID through environment", async () => { + vi.mocked(services.getConnector).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProjectIdFromConnectorId("connector1"); + expect(projectId).toBe("project1"); + expect(services.getConnector).toHaveBeenCalledWith("connector1"); + expect(services.getEnvironment).toHaveBeenCalledWith("env1"); + }); + + test("getProjectIdFromConnectorId throws error when connector not found", async () => { + vi.mocked(services.getConnector).mockResolvedValueOnce(null); + + await expect(getProjectIdFromConnectorId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + expect(services.getConnector).toHaveBeenCalledWith("nonexistent"); + }); }); describe("Environment ID retrieval functions", () => { diff --git a/apps/web/lib/utils/helper.ts b/apps/web/lib/utils/helper.ts index bc4da7ece0..e66b1ecb1e 100644 --- a/apps/web/lib/utils/helper.ts +++ b/apps/web/lib/utils/helper.ts @@ -2,6 +2,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors"; import { getActionClass, getApiKey, + getConnector, getContact, getEnvironment, getIntegration, @@ -329,3 +330,22 @@ export const isStringMatch = (query: string, value: string): boolean => { return valueModified.includes(queryModified); }; + +// Connector helpers +export const getOrganizationIdFromConnectorId = async (connectorId: string) => { + const connector = await getConnector(connectorId); + if (!connector) { + throw new ResourceNotFoundError("connector", connectorId); + } + + return await getOrganizationIdFromEnvironmentId(connector.environmentId); +}; + +export const getProjectIdFromConnectorId = async (connectorId: string) => { + const connector = await getConnector(connectorId); + if (!connector) { + throw new ResourceNotFoundError("connector", connectorId); + } + + return await getProjectIdFromEnvironmentId(connector.environmentId); +}; diff --git a/apps/web/lib/utils/services.test.ts b/apps/web/lib/utils/services.test.ts index aa834b5d2a..fd860a3423 100644 --- a/apps/web/lib/utils/services.test.ts +++ b/apps/web/lib/utils/services.test.ts @@ -8,6 +8,7 @@ import { getQuota as getQuotaService } from "@/modules/ee/quotas/lib/quotas"; import { getActionClass, getApiKey, + getConnector, getContact, getEnvironment, getIntegration, @@ -78,6 +79,9 @@ vi.mock("@formbricks/database", () => ({ contact: { findUnique: vi.fn(), }, + connector: { + findUnique: vi.fn(), + }, segment: { findUnique: vi.fn(), }, @@ -556,4 +560,46 @@ describe("Service Functions", () => { await expect(getSegment(segmentId)).rejects.toThrow(DatabaseError); }); }); + + describe("getConnector", () => { + const connectorId = "connector123"; + + test("returns the connector when found", async () => { + const mockConnector = { environmentId: "env123" }; + vi.mocked(prisma.connector.findUnique).mockResolvedValue(mockConnector); + + const result = await getConnector(connectorId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.connector.findUnique).toHaveBeenCalledWith({ + where: { id: connectorId }, + select: { environmentId: true }, + }); + expect(result).toEqual(mockConnector); + }); + + test("returns null when connector not found", async () => { + vi.mocked(prisma.connector.findUnique).mockResolvedValue(null); + + const result = await getConnector(connectorId); + expect(result).toBeNull(); + }); + + test("throws DatabaseError when Prisma throws a known request error", async () => { + vi.mocked(prisma.connector.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getConnector(connectorId)).rejects.toThrow(DatabaseError); + }); + + test("rethrows unknown errors", async () => { + const unknownError = new Error("Something unexpected"); + vi.mocked(prisma.connector.findUnique).mockRejectedValue(unknownError); + + await expect(getConnector(connectorId)).rejects.toThrow(unknownError); + }); + }); }); diff --git a/apps/web/lib/utils/services.ts b/apps/web/lib/utils/services.ts index 42faad4a24..5b67f81dce 100644 --- a/apps/web/lib/utils/services.ts +++ b/apps/web/lib/utils/services.ts @@ -329,3 +329,25 @@ export const getSegment = reactCache(async (segmentId: string): Promise<{ enviro throw error; } }); + +export const getConnector = reactCache( + async (connectorId: string): Promise<{ environmentId: string } | null> => { + validateInputs([connectorId, ZId]); + try { + const connector = await prisma.connector.findUnique({ + where: { + id: connectorId, + }, + select: { environmentId: true }, + }); + + return connector; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +); diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index dee08ed3cc..bbe2874b17 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -141,6 +141,7 @@ "app_survey": "App-Umfrage", "apply_filters": "Filter anwenden", "are_you_sure": "Bist Du sicher?", + "ask": "Fragen", "attributes": "Attribute", "back": "Zurück", "billing": "Abrechnung", @@ -163,7 +164,7 @@ "code": "Code", "collapse_rows": "Zeilen einklappen", "completed": "Abgeschlossen", - "configuration": "Konfiguration", + "configure": "Konfigurieren", "confirm": "Bestätigen", "connect": "Verbinden", "connect_formbricks": "Formbricks verbinden", @@ -199,6 +200,7 @@ "disallow": "Nicht erlauben", "discard": "Verwerfen", "dismissed": "Entlassen", + "distribute": "Verteilen", "docs": "Dokumentation", "documentation": "Dokumentation", "domain": "Domain", @@ -267,6 +269,7 @@ "logout": "Abmelden", "look_and_feel": "Darstellung", "manage": "Verwalten", + "mappings": "Zuordnungen", "marketing": "Marketing", "member": "Mitglied", "members": "Mitglieder", @@ -428,6 +431,7 @@ "top_right": "Oben rechts", "try_again": "Versuch's nochmal", "type": "Typ", + "unify": "Vereinheitlichen", "unlock_more_workspaces_with_a_higher_plan": "Schalten Sie mehr Projekte mit einem höheren Tarif frei.", "update": "Aktualisierung", "updated": "Aktualisiert", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "Diese Umfrage verwendet Logik." } }, + "unify": { + "add_feedback_source": "Feedback-Quelle hinzufügen", + "add_source": "Quelle hinzufügen", + "are_you_sure": "Bist Du sicher?", + "automated": "Automatisiert", + "aws_region": "AWS-Region", + "change_file": "Datei ändern", + "click_load_sample_csv": "Klicke auf 'Beispiel-CSV laden', um Spalten zu sehen", + "click_to_upload": "Klicke zum Hochladen", + "configure_import": "Import konfigurieren", + "configure_mapping": "Zuordnung konfigurieren", + "connection": "Verbindung", + "connector_created_successfully": "Connector erfolgreich erstellt", + "connector_deleted_successfully": "Connector erfolgreich gelöscht", + "connector_updated_successfully": "Connector erfolgreich aktualisiert", + "copied": "Kopiert!", + "copy": "Kopieren", + "create_mapping": "Zuordnung erstellen", + "csv_at_least_one_row": "CSV muss mindestens eine Datenzeile enthalten.", + "csv_columns": "CSV-Spalten", + "csv_empty_column_headers": "CSV enthält leere Spaltenüberschriften. Alle Spalten müssen einen Namen haben.", + "csv_file_too_large": "Die CSV-Datei ist zu groß. Die maximale Größe beträgt 2 MB.", + "csv_files_only": "Nur CSV-Dateien", + "csv_import": "CSV-Import", + "csv_inconsistent_columns": "Zeile {{row}} hat inkonsistente Spalten. Alle Zeilen müssen die gleichen Überschriften haben.", + "csv_max_records": "Maximal {{max}} Datensätze erlaubt.", + "default_connector_name_csv": "CSV-Import", + "default_connector_name_formbricks": "Formbricks Umfrage-Verbindung", + "delete_source": "Quelle löschen", + "deselect_all": "Alle abwählen", + "drop_a_field_here": "Feld hier ablegen", + "drop_field_or": "Feld ablegen oder", + "drop_zone_path": "Ablagebereich-Pfad", + "edit_source_connection": "Quellverbindung bearbeiten", + "element_selected": "{count} Element ausgewählt. Jede Antwort auf dieses Element erstellt einen FeedbackRecord im Hub.", + "elements_selected": "{count} Elemente ausgewählt. Jede Antwort auf diese Elemente erstellt einen FeedbackRecord im Hub.", + "enable_auto_sync": "Auto-Sync aktivieren", + "enter_name_for_source": "Gib einen Namen für diese Quelle ein", + "enter_value": "Wert eingeben...", + "enum": "Enum", + "every_15_minutes": "Alle 15 Minuten", + "every_30_minutes": "Alle 30 Minuten", + "every_5_minutes": "Alle 5 Minuten", + "every_hour": "Jede Stunde", + "feedback_date": "Feedback-Datum", + "field": "Feld", + "fields": "Felder", + "formbricks_surveys": "Formbricks Umfragen", + "hub_feedback_record_fields": "Hub Feedback-Record-Felder", + "iam_configuration_required": "IAM-Konfiguration erforderlich", + "iam_setup_instructions": "Füge die Formbricks IAM-Rolle zu deiner S3-Bucket-Policy hinzu, um den Zugriff zu ermöglichen.", + "import_csv_data": "CSV-Daten importieren", + "load_sample_csv": "Beispiel-CSV laden", + "n_elements": "{count} Elemente", + "no_source_fields_loaded": "Noch keine Quellfelder geladen", + "no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.", + "no_surveys_found": "Keine Umfragen in dieser Umgebung gefunden", + "optional": "Optional", + "or": "oder", + "or_drag_and_drop": "oder per Drag & Drop", + "process_new_files_description": "Neue Dateien, die im Bucket abgelegt werden, automatisch verarbeiten", + "processing_interval": "Verarbeitungsintervall", + "region_ap_southeast_1": "Asien-Pazifik (Singapur)", + "region_eu_central_1": "EU (Frankfurt)", + "region_eu_west_1": "EU (Irland)", + "region_us_east_1": "US Ost (N. Virginia)", + "region_us_west_2": "US West (Oregon)", + "required": "Erforderlich", + "s3_bucket_description": "Lege CSV-Dateien in deinem S3-Bucket ab, um Feedback automatisch zu importieren. Dateien werden alle 15 Minuten verarbeitet.", + "s3_bucket_integration": "S3-Bucket-Integration", + "save_changes": "Änderungen speichern", + "select_a_survey_to_see_elements": "Wähle eine Umfrage aus, um ihre Elemente zu sehen", + "select_a_value": "Wähle einen Wert aus...", + "select_all": "Alles auswählen", + "select_elements": "Elemente auswählen", + "select_questions": "Fragen auswählen", + "select_source_type_description": "Wähle den Typ der Feedbackquelle aus, die du verbinden möchtest.", + "select_source_type_prompt": "Wähle den Typ der Feedbackquelle, die du verbinden möchtest:", + "select_survey": "Umfrage auswählen", + "select_survey_and_questions": "Umfrage & Fragen auswählen", + "select_survey_questions_description": "Wähle aus, welche Umfragefragen FeedbackRecords erstellen sollen.", + "set_value": "Wert festlegen", + "setup_connection": "Verbindung einrichten", + "showing_rows": "Zeige 3 von {count} Zeilen", + "source_connect_csv_description": "Feedback aus CSV-Dateien importieren", + "source_connect_formbricks_description": "Feedback aus deinen Formbricks-Umfragen verbinden", + "source_fields": "Quellenfelder", + "source_name": "Quellenname", + "source_type_cannot_be_changed": "Quellentyp kann nicht geändert werden", + "sources": "Quellen", + "status_active": "Aktiv", + "status_completed": "Abgeschlossen", + "status_draft": "Entwurf", + "status_error": "Fehler", + "status_paused": "Pausiert", + "survey_has_no_elements": "Diese Umfrage hat keine Fragen", + "test_connection": "Verbindung testen", + "unify_feedback": "Feedback vereinheitlichen", + "update_mapping_description": "Aktualisiere die Mapping-Konfiguration für diese Quelle.", + "upload_csv_data_description": "Lade eine CSV-Datei hoch oder richte automatisierte S3-Importe ein.", + "upload_csv_file": "CSV-Datei hochladen", + "view_setup_guide": "Setup-Anleitung ansehen →", + "yes_delete": "Ja, löschen" + }, "workspace": { "api_keys": { "add_api_key": "API-Schlüssel hinzufügen", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index a0251ae59e..7a3e3b4911 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -49,7 +49,7 @@ "invite_not_found": "Invite not found 😥", "invite_not_found_description": "The invitation code cannot be found or has already been used.", "login": "Login", - "welcome_to_organization": "You are in \uD83C\uDF89", + "welcome_to_organization": "You are in 🎉", "welcome_to_organization_description": "Welcome to the organization." }, "last_used": "Last Used", @@ -141,6 +141,7 @@ "app_survey": "App Survey", "apply_filters": "Apply filters", "are_you_sure": "Are you sure?", + "ask": "Ask", "attributes": "Attributes", "back": "Back", "billing": "Billing", @@ -163,7 +164,7 @@ "code": "Code", "collapse_rows": "Collapse rows", "completed": "Completed", - "configuration": "Configuration", + "configure": "Configure", "confirm": "Confirm", "connect": "Connect", "connect_formbricks": "Connect Formbricks", @@ -199,6 +200,7 @@ "disallow": "Do not allow", "discard": "Discard", "dismissed": "Dismissed", + "distribute": "Distribute", "docs": "Documentation", "documentation": "Documentation", "domain": "Domain", @@ -267,6 +269,7 @@ "logout": "Logout", "look_and_feel": "Look & Feel", "manage": "Manage", + "mappings": "Mappings", "marketing": "Marketing", "member": "Member", "members": "Members", @@ -428,6 +431,7 @@ "top_right": "Top Right", "try_again": "Try again", "type": "Type", + "unify": "Unify", "unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.", "update": "Update", "updated": "Updated", @@ -517,7 +521,7 @@ "select_a_date": "Select a date", "survey_response_finished_email_congrats": "Congrats, you received a new response to your survey! Someone just completed your survey: {surveyName}", "survey_response_finished_email_dont_want_notifications": "Do not want to get these notifications?", - "survey_response_finished_email_hey": "Hey \uD83D\uDC4B", + "survey_response_finished_email_hey": "Hey 👋", "survey_response_finished_email_turn_off_notifications_for_all_new_forms": "Turn off notifications for all newly created forms", "survey_response_finished_email_turn_off_notifications_for_this_form": "Turn off notifications for this form", "survey_response_finished_email_view_more_responses": "View {responseCount} more responses", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "This survey uses branching logic." } }, + "unify": { + "add_feedback_source": "Add Feedback Source", + "add_source": "Add source", + "are_you_sure": "Are you sure?", + "automated": "Automated", + "aws_region": "AWS Region", + "change_file": "Change file", + "click_load_sample_csv": "Click 'Load sample CSV' to see columns", + "click_to_upload": "Click to upload", + "configure_import": "Configure import", + "configure_mapping": "Configure Mapping", + "connection": "Connection", + "connector_created_successfully": "Connector created successfully", + "connector_deleted_successfully": "Connector deleted successfully", + "connector_updated_successfully": "Connector updated successfully", + "copied": "Copied!", + "copy": "Copy", + "create_mapping": "Create mapping", + "csv_at_least_one_row": "CSV must contain at least one data row.", + "csv_columns": "CSV Columns", + "csv_empty_column_headers": "CSV contains empty column headers. All columns must have a name.", + "csv_file_too_large": "CSV file is too large. Maximum size is 2MB.", + "csv_files_only": "CSV files only", + "csv_import": "CSV Import", + "csv_inconsistent_columns": "Row {{row}} has inconsistent columns. All rows must have the same headers.", + "csv_max_records": "Maximum {{max}} records allowed.", + "default_connector_name_csv": "CSV Import", + "default_connector_name_formbricks": "Formbricks Survey Connection", + "delete_source": "Delete source", + "deselect_all": "Deselect all", + "drop_a_field_here": "Drop a field here", + "drop_field_or": "Drop field or", + "drop_zone_path": "Drop zone path", + "edit_source_connection": "Edit Source Connection", + "element_selected": "{count} element selected. Each response to these elements will create a FeedbackRecord in the Hub.", + "elements_selected": "{count} elements selected. Each response to these elements will create a FeedbackRecord in the Hub.", + "enable_auto_sync": "Enable auto-sync", + "enter_name_for_source": "Enter a name for this source", + "enter_value": "Enter value...", + "enum": "enum", + "every_15_minutes": "Every 15 minutes", + "every_30_minutes": "Every 30 minutes", + "every_5_minutes": "Every 5 minutes", + "every_hour": "Every hour", + "feedback_date": "Feedback date", + "field": "field", + "fields": "fields", + "formbricks_surveys": "Formbricks Surveys", + "hub_feedback_record_fields": "Hub Feedback Record Fields", + "iam_configuration_required": "IAM Configuration Required", + "iam_setup_instructions": "Add the Formbricks IAM role to your S3 bucket policy to enable access.", + "import_csv_data": "Import CSV Data", + "load_sample_csv": "Load sample CSV", + "n_elements": "{count} elements", + "no_source_fields_loaded": "No source fields loaded yet", + "no_sources_connected": "No sources connected yet. Add a source to get started.", + "no_surveys_found": "No surveys found in this environment", + "optional": "Optional", + "or": "or", + "or_drag_and_drop": "or drag and drop", + "process_new_files_description": "Automatically process new files dropped in the bucket", + "processing_interval": "Processing interval", + "region_ap_southeast_1": "Asia Pacific (Singapore)", + "region_eu_central_1": "EU (Frankfurt)", + "region_eu_west_1": "EU (Ireland)", + "region_us_east_1": "US East (N. Virginia)", + "region_us_west_2": "US West (Oregon)", + "required": "Required", + "s3_bucket_description": "Drop CSV files into your S3 bucket to automatically import feedback. Files are processed every 15 minutes.", + "s3_bucket_integration": "S3 Bucket Integration", + "save_changes": "Save changes", + "select_a_survey_to_see_elements": "Select a survey to see its elements", + "select_a_value": "Select a value...", + "select_all": "Select all", + "select_elements": "Select Elements", + "select_questions": "Select questions", + "select_source_type_description": "Select the type of feedback source you want to connect.", + "select_source_type_prompt": "Select the type of feedback source you want to connect:", + "select_survey": "Select Survey", + "select_survey_and_questions": "Select Survey & Questions", + "select_survey_questions_description": "Choose which survey questions should create FeedbackRecords.", + "set_value": "set value", + "setup_connection": "Setup connection", + "showing_rows": "Showing 3 of {count} rows", + "source_connect_csv_description": "Import feedback from CSV files", + "source_connect_formbricks_description": "Connect feedback from your Formbricks surveys", + "source_fields": "Source Fields", + "source_name": "Source Name", + "source_type_cannot_be_changed": "Source type cannot be changed", + "sources": "Sources", + "status_active": "Active", + "status_completed": "Completed", + "status_draft": "Draft", + "status_error": "Error", + "status_paused": "Paused", + "survey_has_no_elements": "This survey has no question elements", + "test_connection": "Test connection", + "unify_feedback": "Unify Feedback", + "update_mapping_description": "Update the mapping configuration for this source.", + "upload_csv_data_description": "Upload a CSV file or set up automated S3 imports.", + "upload_csv_file": "Upload CSV File", + "view_setup_guide": "View setup guide →", + "yes_delete": "Yes, delete" + }, "workspace": { "api_keys": { "add_api_key": "Add API Key", @@ -2671,7 +2779,7 @@ "evaluate_a_product_idea_name": "Evaluate a Product Idea", "evaluate_a_product_idea_question_1_button_label": "Let’s do it!", "evaluate_a_product_idea_question_1_headline": "We love how you use $[projectName]! We would love to pick your brain on a feature idea. Got a minute?", - "evaluate_a_product_idea_question_1_html": "

We respect your time and kept it short \uD83E\uDD38

", + "evaluate_a_product_idea_question_1_html": "

We respect your time and kept it short 🤸

", "evaluate_a_product_idea_question_2_headline": "Thanks! How difficult or easy is it for you to [PROBLEM AREA] today?", "evaluate_a_product_idea_question_2_lower_label": "Very difficult", "evaluate_a_product_idea_question_2_upper_label": "Very easy", @@ -2720,8 +2828,8 @@ "feature_chaser_question_2_headline": "Which aspect is most important?", "feedback_box_description": "Give your users the chance to seamlessly share what is on their minds.", "feedback_box_name": "Feedback Box", - "feedback_box_question_1_choice_1": "Bug report \uD83D\uDC1E", - "feedback_box_question_1_choice_2": "Feature Request \uD83D\uDCA1", + "feedback_box_question_1_choice_1": "Bug report 🐞", + "feedback_box_question_1_choice_2": "Feature Request 💡", "feedback_box_question_1_headline": "What is on your mind, boss?", "feedback_box_question_1_subheader": "Thanks for sharing. We will get back to you asap.", "feedback_box_question_2_headline": "What is broken?", @@ -2842,7 +2950,7 @@ "interview_prompt_description": "Invite a specific subset of your users to schedule an interview with your product team.", "interview_prompt_name": "Interview Prompt", "interview_prompt_question_1_button_label": "Book slot", - "interview_prompt_question_1_headline": "Do you have 15 min to talk to us? \uD83D\uDE4F", + "interview_prompt_question_1_headline": "Do you have 15 min to talk to us? 🙏", "interview_prompt_question_1_html": "You are one of our power users. We would love to interview you briefly!", "long_term_retention_check_in_description": "Gauge long-term user satisfaction, loyalty, and areas for improvement to retain loyal users.", "long_term_retention_check_in_name": "Long-Term Retention Check-In", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index bbb623278f..721773a2c4 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -141,6 +141,7 @@ "app_survey": "Encuesta de aplicación", "apply_filters": "Aplicar filtros", "are_you_sure": "¿Estás seguro?", + "ask": "Preguntar", "attributes": "Atributos", "back": "Atrás", "billing": "Facturación", @@ -163,7 +164,7 @@ "code": "Código", "collapse_rows": "Contraer filas", "completed": "Completado", - "configuration": "Configuración", + "configure": "Configurar", "confirm": "Confirmar", "connect": "Conectar", "connect_formbricks": "Conectar Formbricks", @@ -199,6 +200,7 @@ "disallow": "No permitir", "discard": "Descartar", "dismissed": "Descartado", + "distribute": "Distribuir", "docs": "Documentación", "documentation": "Documentación", "domain": "Dominio", @@ -267,6 +269,7 @@ "logout": "Cerrar sesión", "look_and_feel": "Apariencia", "manage": "Gestionar", + "mappings": "Asignaciones", "marketing": "Marketing", "member": "Miembro", "members": "Miembros", @@ -428,6 +431,7 @@ "top_right": "Superior derecha", "try_again": "Intentar de nuevo", "type": "Tipo", + "unify": "Unificar", "unlock_more_workspaces_with_a_higher_plan": "Desbloquea más proyectos con un plan superior.", "update": "Actualizar", "updated": "Actualizado", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "Esta encuesta utiliza lógica de ramificación." } }, + "unify": { + "add_feedback_source": "Añadir fuente de feedback", + "add_source": "Añadir fuente", + "are_you_sure": "¿Estás seguro?", + "automated": "Automatizado", + "aws_region": "Región de AWS", + "change_file": "Cambiar archivo", + "click_load_sample_csv": "Haz clic en 'Cargar CSV de muestra' para ver las columnas", + "click_to_upload": "Haz clic para subir", + "configure_import": "Configurar importación", + "configure_mapping": "Configurar asignación", + "connection": "Conexión", + "connector_created_successfully": "Conector creado correctamente", + "connector_deleted_successfully": "Conector eliminado correctamente", + "connector_updated_successfully": "Conector actualizado correctamente", + "copied": "¡Copiado!", + "copy": "Copiar", + "create_mapping": "Crear asignación", + "csv_at_least_one_row": "El CSV debe contener al menos una fila de datos.", + "csv_columns": "Columnas CSV", + "csv_empty_column_headers": "El CSV contiene encabezados de columna vacíos. Todas las columnas deben tener un nombre.", + "csv_file_too_large": "El archivo CSV es demasiado grande. El tamaño máximo es de 2 MB.", + "csv_files_only": "Solo archivos CSV", + "csv_import": "Importación CSV", + "csv_inconsistent_columns": "La fila {{row}} tiene columnas inconsistentes. Todas las filas deben tener los mismos encabezados.", + "csv_max_records": "Máximo de {{max}} registros permitidos.", + "default_connector_name_csv": "Importación CSV", + "default_connector_name_formbricks": "Conexión de encuesta de Formbricks", + "delete_source": "Eliminar fuente", + "deselect_all": "Deseleccionar todo", + "drop_a_field_here": "Suelta un campo aquí", + "drop_field_or": "Suelta el campo o", + "drop_zone_path": "Ruta de la zona de destino", + "edit_source_connection": "Editar conexión de origen", + "element_selected": "{count} elemento seleccionado. Cada respuesta a este elemento creará un FeedbackRecord en el Hub.", + "elements_selected": "{count} elementos seleccionados. Cada respuesta a estos elementos creará un FeedbackRecord en el Hub.", + "enable_auto_sync": "Activar sincronización automática", + "enter_name_for_source": "Introduce un nombre para este origen", + "enter_value": "Introduce un valor...", + "enum": "enum", + "every_15_minutes": "Cada 15 minutos", + "every_30_minutes": "Cada 30 minutos", + "every_5_minutes": "Cada 5 minutos", + "every_hour": "Cada hora", + "feedback_date": "Fecha del feedback", + "field": "campo", + "fields": "campos", + "formbricks_surveys": "Formbricks Surveys", + "hub_feedback_record_fields": "Campos de FeedbackRecord del Hub", + "iam_configuration_required": "Se requiere configuración de IAM", + "iam_setup_instructions": "Añade el rol de IAM de Formbricks a la política de tu bucket de S3 para habilitar el acceso.", + "import_csv_data": "Importar datos CSV", + "load_sample_csv": "Cargar CSV de muestra", + "n_elements": "{count} elementos", + "no_source_fields_loaded": "Aún no se han cargado campos de origen", + "no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.", + "no_surveys_found": "No se encontraron encuestas en este entorno", + "optional": "Opcional", + "or": "o", + "or_drag_and_drop": "o arrastra y suelta", + "process_new_files_description": "Procesar automáticamente los archivos nuevos depositados en el bucket", + "processing_interval": "Intervalo de procesamiento", + "region_ap_southeast_1": "Asia Pacífico (Singapur)", + "region_eu_central_1": "UE (Fráncfort)", + "region_eu_west_1": "UE (Irlanda)", + "region_us_east_1": "EE. UU. Este (N. Virginia)", + "region_us_west_2": "EE. UU. Oeste (Oregón)", + "required": "Obligatorio", + "s3_bucket_description": "Deposita archivos CSV en tu bucket de S3 para importar comentarios automáticamente. Los archivos se procesan cada 15 minutos.", + "s3_bucket_integration": "Integración con bucket de S3", + "save_changes": "Guardar cambios", + "select_a_survey_to_see_elements": "Selecciona una encuesta para ver sus elementos", + "select_a_value": "Selecciona un valor...", + "select_all": "Seleccionar todo", + "select_elements": "Seleccionar elementos", + "select_questions": "Seleccionar preguntas", + "select_source_type_description": "Selecciona el tipo de fuente de feedback que quieres conectar.", + "select_source_type_prompt": "Selecciona el tipo de fuente de feedback que quieres conectar:", + "select_survey": "Seleccionar encuesta", + "select_survey_and_questions": "Seleccionar encuesta y preguntas", + "select_survey_questions_description": "Elige qué preguntas de la encuesta deben crear FeedbackRecords.", + "set_value": "establecer valor", + "setup_connection": "Configurar conexión", + "showing_rows": "Mostrando 3 de {count} filas", + "source_connect_csv_description": "Importar feedback desde archivos CSV", + "source_connect_formbricks_description": "Conectar feedback de tus encuestas de Formbricks", + "source_fields": "Campos de origen", + "source_name": "Nombre de origen", + "source_type_cannot_be_changed": "El tipo de origen no se puede cambiar", + "sources": "Orígenes", + "status_active": "Activo", + "status_completed": "Completado", + "status_draft": "Borrador", + "status_error": "Error", + "status_paused": "Pausado", + "survey_has_no_elements": "Esta encuesta no tiene elementos de pregunta", + "test_connection": "Probar conexión", + "unify_feedback": "Unificar feedback", + "update_mapping_description": "Actualiza la configuración de mapeo para esta fuente.", + "upload_csv_data_description": "Sube un archivo CSV o configura importaciones automatizadas desde S3.", + "upload_csv_file": "Subir archivo CSV", + "view_setup_guide": "Ver guía de configuración →", + "yes_delete": "Sí, eliminar" + }, "workspace": { "api_keys": { "add_api_key": "Añadir clave API", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index cb969de2b2..4ab68cf6e6 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -141,6 +141,7 @@ "app_survey": "Sondage d'application", "apply_filters": "Appliquer des filtres", "are_you_sure": "Es-tu sûr ?", + "ask": "Demander", "attributes": "Attributs", "back": "Retour", "billing": "Facturation", @@ -163,7 +164,7 @@ "code": "Code", "collapse_rows": "Réduire les lignes", "completed": "Terminé", - "configuration": "Configuration", + "configure": "Configurer", "confirm": "Confirmer", "connect": "Connecter", "connect_formbricks": "Connecter Formbricks", @@ -199,6 +200,7 @@ "disallow": "Ne pas autoriser", "discard": "Annuler", "dismissed": "Rejeté", + "distribute": "Distribuer", "docs": "Documentation", "documentation": "Documentation", "domain": "Domaine", @@ -267,6 +269,7 @@ "logout": "Déconnexion", "look_and_feel": "Apparence", "manage": "Gérer", + "mappings": "Mappages", "marketing": "Marketing", "member": "Membre", "members": "Membres", @@ -428,6 +431,7 @@ "top_right": "En haut à droite", "try_again": "Réessayer", "type": "Type", + "unify": "Unifier", "unlock_more_workspaces_with_a_higher_plan": "Débloquez plus de projets avec un forfait supérieur.", "update": "Mise à jour", "updated": "Mise à jour", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "Cette enquête utilise une logique de branchement." } }, + "unify": { + "add_feedback_source": "Ajouter une source de feedback", + "add_source": "Ajouter une source", + "are_you_sure": "Es-tu sûr ?", + "automated": "Automatisé", + "aws_region": "Région AWS", + "change_file": "Changer de fichier", + "click_load_sample_csv": "Clique sur « Charger un exemple CSV » pour voir les colonnes", + "click_to_upload": "Clique pour charger", + "configure_import": "Configurer l'importation", + "configure_mapping": "Configurer le mappage", + "connection": "Connexion", + "connector_created_successfully": "Connecteur créé avec succès", + "connector_deleted_successfully": "Connecteur supprimé avec succès", + "connector_updated_successfully": "Connecteur mis à jour avec succès", + "copied": "Copié !", + "copy": "Copier", + "create_mapping": "Créer un mappage", + "csv_at_least_one_row": "Le CSV doit contenir au moins une ligne de données.", + "csv_columns": "Colonnes CSV", + "csv_empty_column_headers": "Le CSV contient des en-têtes de colonnes vides. Toutes les colonnes doivent avoir un nom.", + "csv_file_too_large": "Le fichier CSV est trop volumineux. La taille maximale est de 2 Mo.", + "csv_files_only": "Fichiers CSV uniquement", + "csv_import": "Importation CSV", + "csv_inconsistent_columns": "La ligne {{row}} a des colonnes incohérentes. Toutes les lignes doivent avoir les mêmes en-têtes.", + "csv_max_records": "Maximum {{max}} enregistrements autorisés.", + "default_connector_name_csv": "Importation CSV", + "default_connector_name_formbricks": "Connexion de sondage Formbricks", + "delete_source": "Supprimer la source", + "deselect_all": "Tout désélectionner", + "drop_a_field_here": "Déposez un champ ici", + "drop_field_or": "Déposez un champ ou", + "drop_zone_path": "Chemin de la zone de dépôt", + "edit_source_connection": "Modifier la connexion source", + "element_selected": "{count} élément sélectionné. Chaque réponse à cet élément créera un FeedbackRecord dans le Hub.", + "elements_selected": "{count} éléments sélectionnés. Chaque réponse à ces éléments créera un FeedbackRecord dans le Hub.", + "enable_auto_sync": "Activer la synchronisation automatique", + "enter_name_for_source": "Entrez un nom pour cette source", + "enter_value": "Saisir une valeur...", + "enum": "enum", + "every_15_minutes": "Toutes les 15 minutes", + "every_30_minutes": "Toutes les 30 minutes", + "every_5_minutes": "Toutes les 5 minutes", + "every_hour": "Toutes les heures", + "feedback_date": "Date du feedback", + "field": "champ", + "fields": "champs", + "formbricks_surveys": "Sondages Formbricks", + "hub_feedback_record_fields": "Champs d'enregistrement de feedback du Hub", + "iam_configuration_required": "Configuration IAM requise", + "iam_setup_instructions": "Ajoutez le rôle IAM Formbricks à la politique de votre bucket S3 pour activer l'accès.", + "import_csv_data": "Importer des données CSV", + "load_sample_csv": "Charger un exemple de CSV", + "n_elements": "{count, plural, one {# élément} other {# éléments}}", + "no_source_fields_loaded": "Aucun champ source chargé pour le moment", + "no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.", + "no_surveys_found": "Aucune enquête trouvée dans cet environnement", + "optional": "Facultatif", + "or": "ou", + "or_drag_and_drop": "ou glisser-déposer", + "process_new_files_description": "Traiter automatiquement les nouveaux fichiers déposés dans le bucket", + "processing_interval": "Intervalle de traitement", + "region_ap_southeast_1": "Asie-Pacifique (Singapour)", + "region_eu_central_1": "UE (Francfort)", + "region_eu_west_1": "UE (Irlande)", + "region_us_east_1": "Est des États-Unis (Virginie du Nord)", + "region_us_west_2": "Ouest des États-Unis (Oregon)", + "required": "Requis", + "s3_bucket_description": "Déposez des fichiers CSV dans votre bucket S3 pour importer automatiquement les retours. Les fichiers sont traités toutes les 15 minutes.", + "s3_bucket_integration": "Intégration de bucket S3", + "save_changes": "Enregistrer les modifications", + "select_a_survey_to_see_elements": "Sélectionnez une enquête pour voir ses éléments", + "select_a_value": "Sélectionnez une valeur...", + "select_all": "Sélectionner tout", + "select_elements": "Sélectionner les éléments", + "select_questions": "Sélectionner les questions", + "select_source_type_description": "Sélectionnez le type de source de feedback que vous souhaitez connecter.", + "select_source_type_prompt": "Sélectionnez le type de source de feedback que vous souhaitez connecter :", + "select_survey": "Sélectionner l'enquête", + "select_survey_and_questions": "Sélectionner l'enquête et les questions", + "select_survey_questions_description": "Choisissez quelles questions d'enquête doivent créer des FeedbackRecords.", + "set_value": "définir la valeur", + "setup_connection": "Configurer la connexion", + "showing_rows": "Affichage de 3 sur {count} lignes", + "source_connect_csv_description": "Importer des feedbacks depuis des fichiers CSV", + "source_connect_formbricks_description": "Connecter les feedbacks de vos enquêtes Formbricks", + "source_fields": "Champs source", + "source_name": "Nom de la source", + "source_type_cannot_be_changed": "Le type de source ne peut pas être modifié", + "sources": "Sources", + "status_active": "Active", + "status_completed": "Terminé", + "status_draft": "Brouillon", + "status_error": "Erreur", + "status_paused": "En pause", + "survey_has_no_elements": "Cette enquête n'a aucun élément de question", + "test_connection": "Tester la connexion", + "unify_feedback": "Unifier les retours", + "update_mapping_description": "Mettre à jour la configuration de mappage pour cette source.", + "upload_csv_data_description": "Télécharger un fichier CSV ou configurer des imports S3 automatisés.", + "upload_csv_file": "Télécharger un fichier CSV", + "view_setup_guide": "Voir le guide de configuration →", + "yes_delete": "Oui, supprimer" + }, "workspace": { "api_keys": { "add_api_key": "Ajouter une clé API", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 15ad9deb5b..9712e7337f 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -141,6 +141,7 @@ "app_survey": "Alkalmazás-kérdőív", "apply_filters": "Szűrők alkalmazása", "are_you_sure": "Biztos benne?", + "ask": "Kérdezz", "attributes": "Attribútumok", "back": "Vissza", "billing": "Számlázás", @@ -163,7 +164,7 @@ "code": "Kód", "collapse_rows": "Sorok összecsukása", "completed": "Befejezve", - "configuration": "Beállítás", + "configure": "Konfigurálás", "confirm": "Megerősítés", "connect": "Kapcsolódás", "connect_formbricks": "Kapcsolódás a Formbrickshez", @@ -199,6 +200,7 @@ "disallow": "Ne engedélyezze", "discard": "Elvetés", "dismissed": "Eltüntetve", + "distribute": "Oszd meg", "docs": "Dokumentáció", "documentation": "Dokumentáció", "domain": "Tartomány", @@ -267,6 +269,7 @@ "logout": "Kijelentkezés", "look_and_feel": "Megjelenés", "manage": "Kezelés", + "mappings": "Leképezések", "marketing": "Marketing", "member": "Tag", "members": "Tagok", @@ -428,6 +431,7 @@ "top_right": "Jobbra fent", "try_again": "Próbálja újra", "type": "Típus", + "unify": "Egyesíts", "unlock_more_workspaces_with_a_higher_plan": "Több munkaterület feloldása egy magasabb csomaggal.", "update": "Frissítés", "updated": "Frissítve", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "Ez a kérdőív elágazási logikát használ." } }, + "unify": { + "add_feedback_source": "Visszajelzési forrás hozzáadása", + "add_source": "Forrás hozzáadása", + "are_you_sure": "Biztos benne?", + "automated": "Automatizált", + "aws_region": "AWS régió", + "change_file": "Fájl módosítása", + "click_load_sample_csv": "Kattintson a 'Minta CSV betöltése' gombra az oszlopok megtekintéséhez", + "click_to_upload": "Kattintson a feltöltéshez", + "configure_import": "Importálás konfigurálása", + "configure_mapping": "Leképezés konfigurálása", + "connection": "Kapcsolat", + "connector_created_successfully": "Csatlakozó sikeresen létrehozva", + "connector_deleted_successfully": "Csatlakozó sikeresen törölve", + "connector_updated_successfully": "Csatlakozó sikeresen frissítve", + "copied": "Másolva!", + "copy": "Másolás", + "create_mapping": "Leképezés létrehozása", + "csv_at_least_one_row": "A CSV-nek legalább egy adatsort kell tartalmaznia.", + "csv_columns": "CSV oszlopok", + "csv_empty_column_headers": "A CSV üres oszlopfejléceket tartalmaz. Minden oszlopnak rendelkeznie kell névvel.", + "csv_file_too_large": "A CSV fájl túl nagy. A maximális méret 2 MB.", + "csv_files_only": "Csak CSV fájlok", + "csv_import": "CSV importálás", + "csv_inconsistent_columns": "A(z) {{row}}. sor inkonzisztens oszlopokat tartalmaz. Minden sornak ugyanazokkal a fejlécekkel kell rendelkeznie.", + "csv_max_records": "Legfeljebb {{max}} rekord engedélyezett.", + "default_connector_name_csv": "CSV importálás", + "default_connector_name_formbricks": "Formbricks kérdőív kapcsolat", + "delete_source": "Forrás törlése", + "deselect_all": "Összes kijelölés törlése", + "drop_a_field_here": "Húzz ide egy mezőt", + "drop_field_or": "Húzz ide egy mezőt vagy", + "drop_zone_path": "Célterület elérési útja", + "edit_source_connection": "Forráskapcsolat szerkesztése", + "element_selected": "{count} elem kiválasztva. Az ezekre az elemekre adott minden válasz létrehoz egy FeedbackRecord-ot a központban.", + "elements_selected": "{count} elem kiválasztva. Az ezekre az elemekre adott minden válasz létrehoz egy FeedbackRecord-ot a központban.", + "enable_auto_sync": "Automatikus szinkronizálás engedélyezése", + "enter_name_for_source": "Adj nevet ennek a forrásnak", + "enter_value": "Érték megadása...", + "enum": "felsorolás", + "every_15_minutes": "15 percenként", + "every_30_minutes": "30 percenként", + "every_5_minutes": "5 percenként", + "every_hour": "Óránként", + "feedback_date": "Visszajelzés dátuma", + "field": "mező", + "fields": "mezők", + "formbricks_surveys": "Formbricks kérdőívek", + "hub_feedback_record_fields": "Központi visszajelzési rekord mezők", + "iam_configuration_required": "IAM konfiguráció szükséges", + "iam_setup_instructions": "Add hozzá a Formbricks IAM szerepkört az S3 bucket szabályzatodhoz a hozzáférés engedélyezéséhez.", + "import_csv_data": "CSV adatok importálása", + "load_sample_csv": "Minta CSV betöltése", + "n_elements": "{count, plural, one {# elem} other {# elem}}", + "no_source_fields_loaded": "Még nincsenek forrás mezők betöltve", + "no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.", + "no_surveys_found": "Nem találhatók kérdőívek ebben a környezetben", + "optional": "Elhagyható", + "or": "vagy", + "or_drag_and_drop": "vagy húzd ide", + "process_new_files_description": "Új fájlok automatikus feldolgozása a tárolóba helyezéskor", + "processing_interval": "Feldolgozási időköz", + "region_ap_southeast_1": "Ázsia-Csendes-óceáni térség (Szingapúr)", + "region_eu_central_1": "EU (Frankfurt)", + "region_eu_west_1": "EU (Írország)", + "region_us_east_1": "USA keleti régió (Észak-Virginia)", + "region_us_west_2": "USA nyugati régió (Oregon)", + "required": "Kötelező", + "s3_bucket_description": "Helyezz CSV fájlokat az S3 tárolódba a visszajelzések automatikus importálásához. A fájlok 15 percenként kerülnek feldolgozásra.", + "s3_bucket_integration": "S3 tároló integráció", + "save_changes": "Változtatások mentése", + "select_a_survey_to_see_elements": "Válassz egy kérdőívet az elemek megtekintéséhez", + "select_a_value": "Válassz egy értéket...", + "select_all": "Összes kiválasztása", + "select_elements": "Elemek kiválasztása", + "select_questions": "Kérdések kiválasztása", + "select_source_type_description": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát.", + "select_source_type_prompt": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát:", + "select_survey": "Kérdőív kiválasztása", + "select_survey_and_questions": "Kérdőív és kérdések kiválasztása", + "select_survey_questions_description": "Válassza ki, mely kérdőívkérdések hozzanak létre visszajelzési rekordokat.", + "set_value": "érték beállítása", + "setup_connection": "Kapcsolat beállítása", + "showing_rows": "3 megjelenítve {count} sorból", + "source_connect_csv_description": "Visszajelzések importálása CSV fájlokból", + "source_connect_formbricks_description": "Visszajelzések csatlakoztatása a Formbricks kérdőívekből", + "source_fields": "Forrásmezők", + "source_name": "Forrásnév", + "source_type_cannot_be_changed": "A forrástípus nem módosítható", + "sources": "Források", + "status_active": "Aktív", + "status_completed": "Befejezve", + "status_draft": "Piszkozat", + "status_error": "Hiba", + "status_paused": "Szüneteltetve", + "survey_has_no_elements": "Ez a kérdőív nem tartalmaz kérdéselemeket", + "test_connection": "Kapcsolat tesztelése", + "unify_feedback": "Visszajelzések egyesítése", + "update_mapping_description": "Frissítse a leképezési konfigurációt ehhez a forráshoz.", + "upload_csv_data_description": "Töltsön fel egy CSV fájlt, vagy állítson be automatizált S3 importálást.", + "upload_csv_file": "CSV fájl feltöltése", + "view_setup_guide": "Telepítési útmutató megtekintése →", + "yes_delete": "Igen, törlés" + }, "workspace": { "api_keys": { "add_api_key": "API-kulcs hozzáadása", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 5972a3d8e3..b0b3d25244 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -141,6 +141,7 @@ "app_survey": "アプリ内フォーム", "apply_filters": "フィルターを適用", "are_you_sure": "よろしいですか?", + "ask": "質問する", "attributes": "属性", "back": "戻る", "billing": "請求", @@ -163,7 +164,7 @@ "code": "コード", "collapse_rows": "行を非表示", "completed": "完了", - "configuration": "設定", + "configure": "設定", "confirm": "確認", "connect": "接続", "connect_formbricks": "Formbricksを接続", @@ -199,6 +200,7 @@ "disallow": "許可しない", "discard": "破棄", "dismissed": "非表示", + "distribute": "配布する", "docs": "ドキュメント", "documentation": "ドキュメント", "domain": "ドメイン", @@ -267,6 +269,7 @@ "logout": "ログアウト", "look_and_feel": "デザイン", "manage": "管理", + "mappings": "マッピング", "marketing": "マーケティング", "member": "メンバー", "members": "メンバー", @@ -428,6 +431,7 @@ "top_right": "右上", "try_again": "もう一度お試しください", "type": "種類", + "unify": "統合する", "unlock_more_workspaces_with_a_higher_plan": "上位プランでより多くのワークスペースを利用できます。", "update": "更新", "updated": "更新済み", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "このフォームは分岐ロジックを使用しています。" } }, + "unify": { + "add_feedback_source": "フィードバックソースを追加", + "add_source": "ソースを追加", + "are_you_sure": "よろしいですか?", + "automated": "自動化", + "aws_region": "AWSリージョン", + "change_file": "ファイルを変更", + "click_load_sample_csv": "「サンプルCSVを読み込む」をクリックして列を表示", + "click_to_upload": "クリックしてアップロード", + "configure_import": "インポートを設定", + "configure_mapping": "マッピングを設定", + "connection": "接続", + "connector_created_successfully": "コネクタが正常に作成されました", + "connector_deleted_successfully": "コネクタが正常に削除されました", + "connector_updated_successfully": "コネクタが正常に更新されました", + "copied": "コピーしました!", + "copy": "コピー", + "create_mapping": "マッピングを作成", + "csv_at_least_one_row": "CSVには少なくとも1行のデータが必要です。", + "csv_columns": "CSV列", + "csv_empty_column_headers": "CSVに空の列ヘッダーが含まれています。すべての列に名前が必要です。", + "csv_file_too_large": "CSVファイルが大きすぎます。最大サイズは2MBです。", + "csv_files_only": "CSVファイルのみ", + "csv_import": "CSVインポート", + "csv_inconsistent_columns": "{{row}}行目の列が一致しません。すべての行で同じヘッダーが必要です。", + "csv_max_records": "最大{{max}}件のレコードまで許可されています。", + "default_connector_name_csv": "CSVインポート", + "default_connector_name_formbricks": "Formbricks フォーム接続", + "delete_source": "ソースを削除", + "deselect_all": "すべて選択解除", + "drop_a_field_here": "ここにフィールドをドロップ", + "drop_field_or": "フィールドをドロップまたは", + "drop_zone_path": "ドロップゾーンのパス", + "edit_source_connection": "ソース接続を編集", + "element_selected": "{count}個の要素が選択されています。これらの要素への各回答は、ハブにフィードバックレコードを作成します。", + "elements_selected": "{count}個の要素が選択されています。これらの要素への各回答は、ハブにフィードバックレコードを作成します。", + "enable_auto_sync": "自動同期を有効にする", + "enter_name_for_source": "このソースの名前を入力", + "enter_value": "値を入力...", + "enum": "列挙型", + "every_15_minutes": "15分ごと", + "every_30_minutes": "30分ごと", + "every_5_minutes": "5分ごと", + "every_hour": "1時間ごと", + "feedback_date": "フィードバック日時", + "field": "フィールド", + "fields": "フィールド", + "formbricks_surveys": "Formbricks フォーム", + "hub_feedback_record_fields": "ハブフィードバックレコードフィールド", + "iam_configuration_required": "IAM設定が必要です", + "iam_setup_instructions": "S3バケットポリシーにFormbricks IAMロールを追加して、アクセスを有効にしてください。", + "import_csv_data": "CSVデータをインポート", + "load_sample_csv": "サンプルCSVを読み込む", + "n_elements": "{count}個の要素", + "no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません", + "no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。", + "no_surveys_found": "この環境にフォームが見つかりません", + "optional": "任意", + "or": "または", + "or_drag_and_drop": "またはドラッグ&ドロップ", + "process_new_files_description": "バケットにドロップされた新しいファイルを自動的に処理します", + "processing_interval": "処理間隔", + "region_ap_southeast_1": "アジアパシフィック(シンガポール)", + "region_eu_central_1": "EU(フランクフルト)", + "region_eu_west_1": "EU(アイルランド)", + "region_us_east_1": "米国東部(バージニア北部)", + "region_us_west_2": "米国西部(オレゴン)", + "required": "必須", + "s3_bucket_description": "S3バケットにCSVファイルをドロップすると、フィードバックが自動的にインポートされます。ファイルは15分ごとに処理されます。", + "s3_bucket_integration": "S3バケット連携", + "save_changes": "変更を保存", + "select_a_survey_to_see_elements": "フォームを選択して要素を表示", + "select_a_value": "値を選択...", + "select_all": "すべて選択", + "select_elements": "要素を選択", + "select_questions": "質問を選択", + "select_source_type_description": "接続するフィードバックソースの種類を選択してください。", + "select_source_type_prompt": "接続するフィードバックソースの種類を選択してください:", + "select_survey": "フォームを選択", + "select_survey_and_questions": "フォームと質問を選択", + "select_survey_questions_description": "フィードバックレコードを作成するフォームの質問を選択してください。", + "set_value": "値を設定", + "setup_connection": "接続を設定", + "showing_rows": "{count}行中3行を表示", + "source_connect_csv_description": "CSVファイルからフィードバックをインポート", + "source_connect_formbricks_description": "Formbricksフォームからフィードバックを接続", + "source_fields": "ソースフィールド", + "source_name": "ソース名", + "source_type_cannot_be_changed": "ソースタイプは変更できません", + "sources": "ソース", + "status_active": "有効", + "status_completed": "完了", + "status_draft": "下書き", + "status_error": "エラー", + "status_paused": "一時停止", + "survey_has_no_elements": "このフォームには質問要素がありません", + "test_connection": "接続をテスト", + "unify_feedback": "フィードバックを統合", + "update_mapping_description": "このソースのマッピング設定を更新します。", + "upload_csv_data_description": "CSVファイルをアップロードするか、S3の自動インポートを設定します。", + "upload_csv_file": "CSVファイルをアップロード", + "view_setup_guide": "セットアップガイドを見る →", + "yes_delete": "はい、削除します" + }, "workspace": { "api_keys": { "add_api_key": "APIキーを追加", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index b4fc6accea..93764bd05f 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -141,6 +141,7 @@ "app_survey": "App-enquête", "apply_filters": "Pas filters toe", "are_you_sure": "Weet je het zeker?", + "ask": "Vraag", "attributes": "Kenmerken", "back": "Rug", "billing": "Facturering", @@ -163,7 +164,7 @@ "code": "Code", "collapse_rows": "Rijen samenvouwen", "completed": "Voltooid", - "configuration": "Configuratie", + "configure": "Configureren", "confirm": "Bevestigen", "connect": "Verbinden", "connect_formbricks": "Sluit Formbricks aan", @@ -199,6 +200,7 @@ "disallow": "Niet toestaan", "discard": "Weggooien", "dismissed": "Afgewezen", + "distribute": "Distribueer", "docs": "Documentatie", "documentation": "Documentatie", "domain": "Domein", @@ -267,6 +269,7 @@ "logout": "Uitloggen", "look_and_feel": "Kijk & voel", "manage": "Beheren", + "mappings": "Koppelingen", "marketing": "Marketing", "member": "Lid", "members": "Leden", @@ -428,6 +431,7 @@ "top_right": "Rechtsboven", "try_again": "Probeer het opnieuw", "type": "Type", + "unify": "Verenig", "unlock_more_workspaces_with_a_higher_plan": "Ontgrendel meer werkruimtes met een hoger abonnement.", "update": "Update", "updated": "Bijgewerkt", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "Dit onderzoek maakt gebruik van vertakkingslogica." } }, + "unify": { + "add_feedback_source": "Feedbackbron toevoegen", + "add_source": "Bron toevoegen", + "are_you_sure": "Weet je het zeker?", + "automated": "Geautomatiseerd", + "aws_region": "AWS regio", + "change_file": "Bestand wijzigen", + "click_load_sample_csv": "Klik op 'Voorbeeld CSV laden' om kolommen te zien", + "click_to_upload": "Klik om te uploaden", + "configure_import": "Import configureren", + "configure_mapping": "Koppeling configureren", + "connection": "Verbinding", + "connector_created_successfully": "Connector succesvol aangemaakt", + "connector_deleted_successfully": "Connector succesvol verwijderd", + "connector_updated_successfully": "Connector succesvol bijgewerkt", + "copied": "Gekopieerd!", + "copy": "Kopiëren", + "create_mapping": "Koppeling aanmaken", + "csv_at_least_one_row": "CSV moet minimaal één datarij bevatten.", + "csv_columns": "CSV kolommen", + "csv_empty_column_headers": "CSV bevat lege kolomkoppen. Alle kolommen moeten een naam hebben.", + "csv_file_too_large": "CSV-bestand is te groot. Maximale grootte is 2MB.", + "csv_files_only": "Alleen CSV bestanden", + "csv_import": "CSV import", + "csv_inconsistent_columns": "Rij {{row}} heeft inconsistente kolommen. Alle rijen moeten dezelfde koppen hebben.", + "csv_max_records": "Maximaal {{max}} records toegestaan.", + "default_connector_name_csv": "CSV import", + "default_connector_name_formbricks": "Formbricks Survey verbinding", + "delete_source": "Bron verwijderen", + "deselect_all": "Alles deselecteren", + "drop_a_field_here": "Zet hier een veld neer", + "drop_field_or": "Zet veld neer of", + "drop_zone_path": "Drop zone pad", + "edit_source_connection": "Bronverbinding bewerken", + "element_selected": "{count} element geselecteerd. Elke reactie op dit element zal een FeedbackRecord aanmaken in de Hub.", + "elements_selected": "{count} elementen geselecteerd. Elke reactie op deze elementen zal een FeedbackRecord aanmaken in de Hub.", + "enable_auto_sync": "Automatische synchronisatie inschakelen", + "enter_name_for_source": "Voer een naam in voor deze bron", + "enter_value": "Voer waarde in...", + "enum": "enum", + "every_15_minutes": "Elke 15 minuten", + "every_30_minutes": "Elke 30 minuten", + "every_5_minutes": "Elke 5 minuten", + "every_hour": "Elk uur", + "feedback_date": "Feedbackdatum", + "field": "veld", + "fields": "velden", + "formbricks_surveys": "Formbricks Surveys", + "hub_feedback_record_fields": "Hub feedbackrecordvelden", + "iam_configuration_required": "IAM-configuratie vereist", + "iam_setup_instructions": "Voeg de Formbricks IAM-rol toe aan je S3 bucket policy om toegang mogelijk te maken.", + "import_csv_data": "CSV-gegevens importeren", + "load_sample_csv": "Voorbeeld-CSV laden", + "n_elements": "{count} elementen", + "no_source_fields_loaded": "Nog geen bronvelden geladen", + "no_sources_connected": "Nog geen bronnen verbonden. Voeg een bron toe om te beginnen.", + "no_surveys_found": "Geen enquêtes gevonden in deze omgeving", + "optional": "Optioneel", + "or": "of", + "or_drag_and_drop": "of sleep en zet neer", + "process_new_files_description": "Verwerk automatisch nieuwe bestanden die in de bucket worden geplaatst", + "processing_interval": "Verwerkingsinterval", + "region_ap_southeast_1": "Azië-Pacific (Singapore)", + "region_eu_central_1": "EU (Frankfurt)", + "region_eu_west_1": "EU (Ierland)", + "region_us_east_1": "VS Oost (N. Virginia)", + "region_us_west_2": "VS West (Oregon)", + "required": "Vereist", + "s3_bucket_description": "Plaats CSV-bestanden in je S3-bucket om automatisch feedback te importeren. Bestanden worden elke 15 minuten verwerkt.", + "s3_bucket_integration": "S3-bucket integratie", + "save_changes": "Wijzigingen opslaan", + "select_a_survey_to_see_elements": "Selecteer een enquête om de elementen te zien", + "select_a_value": "Selecteer een waarde...", + "select_all": "Selecteer alles", + "select_elements": "Selecteer elementen", + "select_questions": "Selecteer vragen", + "select_source_type_description": "Selecteer het type feedbackbron dat je wilt verbinden.", + "select_source_type_prompt": "Selecteer het type feedbackbron dat je wilt verbinden:", + "select_survey": "Selecteer enquête", + "select_survey_and_questions": "Selecteer enquête & vragen", + "select_survey_questions_description": "Kies welke enquêtevragen FeedbackRecords moeten aanmaken.", + "set_value": "waarde instellen", + "setup_connection": "Verbinding instellen", + "showing_rows": "3 van {count} rijen weergegeven", + "source_connect_csv_description": "Importeer feedback uit CSV-bestanden", + "source_connect_formbricks_description": "Verbind feedback van je Formbricks-enquêtes", + "source_fields": "Bronvelden", + "source_name": "Bronnaam", + "source_type_cannot_be_changed": "Brontype kan niet worden gewijzigd", + "sources": "Bronnen", + "status_active": "Actief", + "status_completed": "Voltooid", + "status_draft": "Voorlopige versie", + "status_error": "Fout", + "status_paused": "Gepauzeerd", + "survey_has_no_elements": "Deze enquête heeft geen vraagelementen", + "test_connection": "Verbinding testen", + "unify_feedback": "Feedback verenigen", + "update_mapping_description": "Werk de mappingconfiguratie voor deze bron bij.", + "upload_csv_data_description": "Upload een CSV-bestand of stel geautomatiseerde S3-imports in.", + "upload_csv_file": "CSV-bestand uploaden", + "view_setup_guide": "Bekijk installatiegids →", + "yes_delete": "Ja, verwijderen" + }, "workspace": { "api_keys": { "add_api_key": "API-sleutel toevoegen", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 235b4fa0c5..32bd4599db 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -141,6 +141,7 @@ "app_survey": "Pesquisa de App", "apply_filters": "Aplicar filtros", "are_you_sure": "Certeza?", + "ask": "Perguntar", "attributes": "atributos", "back": "Voltar", "billing": "Faturamento", @@ -163,7 +164,7 @@ "code": "Código", "collapse_rows": "Recolher linhas", "completed": "Concluído", - "configuration": "Configuração", + "configure": "Configurar", "confirm": "Confirmar", "connect": "Conectar", "connect_formbricks": "Conectar Formbricks", @@ -199,6 +200,7 @@ "disallow": "Não permita", "discard": "Descartar", "dismissed": "Dispensado", + "distribute": "Distribuir", "docs": "Documentação", "documentation": "Documentação", "domain": "Domínio", @@ -267,6 +269,7 @@ "logout": "Sair", "look_and_feel": "Aparência e Experiência", "manage": "gerenciar", + "mappings": "Mapeamentos", "marketing": "marketing", "member": "Membros", "members": "Membros", @@ -428,6 +431,7 @@ "top_right": "Canto Superior Direito", "try_again": "Tenta de novo", "type": "Tipo", + "unify": "Unificar", "unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.", "update": "atualizar", "updated": "atualizado", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "Essa pesquisa usa lógica de ramificação." } }, + "unify": { + "add_feedback_source": "Adicionar fonte de feedback", + "add_source": "Adicionar fonte", + "are_you_sure": "Certeza?", + "automated": "Automatizado", + "aws_region": "Região AWS", + "change_file": "Alterar arquivo", + "click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas", + "click_to_upload": "Clique para fazer upload", + "configure_import": "Configurar importação", + "configure_mapping": "Configurar mapeamento", + "connection": "Conexão", + "connector_created_successfully": "Conector criado com sucesso", + "connector_deleted_successfully": "Conector excluído com sucesso", + "connector_updated_successfully": "Conector atualizado com sucesso", + "copied": "Copiado!", + "copy": "Copiar", + "create_mapping": "Criar mapeamento", + "csv_at_least_one_row": "O CSV deve conter pelo menos uma linha de dados.", + "csv_columns": "Colunas CSV", + "csv_empty_column_headers": "O CSV contém cabeçalhos de coluna vazios. Todas as colunas devem ter um nome.", + "csv_file_too_large": "O arquivo CSV é muito grande. O tamanho máximo é 2MB.", + "csv_files_only": "Apenas arquivos CSV", + "csv_import": "Importação CSV", + "csv_inconsistent_columns": "A linha {{row}} possui colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.", + "csv_max_records": "Máximo de {{max}} registros permitidos.", + "default_connector_name_csv": "Importação CSV", + "default_connector_name_formbricks": "Conexão de pesquisa Formbricks", + "delete_source": "Excluir fonte", + "deselect_all": "Desmarcar tudo", + "drop_a_field_here": "Solte um campo aqui", + "drop_field_or": "Solte o campo ou", + "drop_zone_path": "Caminho da zona de soltar", + "edit_source_connection": "Editar conexão de origem", + "element_selected": "{count} elemento selecionado. Cada resposta a este elemento criará um FeedbackRecord no Hub.", + "elements_selected": "{count} elementos selecionados. Cada resposta a estes elementos criará um FeedbackRecord no Hub.", + "enable_auto_sync": "Ativar sincronização automática", + "enter_name_for_source": "Digite um nome para esta origem", + "enter_value": "Digite o valor...", + "enum": "enum", + "every_15_minutes": "A cada 15 minutos", + "every_30_minutes": "A cada 30 minutos", + "every_5_minutes": "A cada 5 minutos", + "every_hour": "A cada hora", + "feedback_date": "Data do feedback", + "field": "campo", + "fields": "campos", + "formbricks_surveys": "Pesquisas Formbricks", + "hub_feedback_record_fields": "Campos de registro de feedback do Hub", + "iam_configuration_required": "Configuração IAM necessária", + "iam_setup_instructions": "Adicione a função IAM do Formbricks à política do seu bucket S3 para habilitar o acesso.", + "import_csv_data": "Importar dados CSV", + "load_sample_csv": "Carregar CSV de exemplo", + "n_elements": "{count} elementos", + "no_source_fields_loaded": "Nenhum campo de origem carregado ainda", + "no_sources_connected": "Nenhuma origem conectada ainda. Adicione uma origem para começar.", + "no_surveys_found": "Nenhuma pesquisa encontrada neste ambiente", + "optional": "Opcional", + "or": "ou", + "or_drag_and_drop": "ou arraste e solte", + "process_new_files_description": "Processar automaticamente novos arquivos adicionados ao bucket", + "processing_interval": "Intervalo de processamento", + "region_ap_southeast_1": "Ásia-Pacífico (Singapura)", + "region_eu_central_1": "UE (Frankfurt)", + "region_eu_west_1": "UE (Irlanda)", + "region_us_east_1": "Leste dos EUA (Norte da Virgínia)", + "region_us_west_2": "Oeste dos EUA (Oregon)", + "required": "Obrigatório", + "s3_bucket_description": "Adicione arquivos CSV ao seu bucket S3 para importar feedback automaticamente. Os arquivos são processados a cada 15 minutos.", + "s3_bucket_integration": "Integração com bucket S3", + "save_changes": "Salvar alterações", + "select_a_survey_to_see_elements": "Selecione uma pesquisa para ver seus elementos", + "select_a_value": "Selecione um valor...", + "select_all": "Selecionar tudo", + "select_elements": "Selecionar elementos", + "select_questions": "Selecionar perguntas", + "select_source_type_description": "Selecione o tipo de fonte de feedback que você deseja conectar.", + "select_source_type_prompt": "Selecione o tipo de fonte de feedback que você deseja conectar:", + "select_survey": "Selecionar pesquisa", + "select_survey_and_questions": "Selecionar pesquisa e perguntas", + "select_survey_questions_description": "Escolha quais perguntas da pesquisa devem criar FeedbackRecords.", + "set_value": "definir valor", + "setup_connection": "Configurar conexão", + "showing_rows": "Mostrando 3 de {count} linhas", + "source_connect_csv_description": "Importar feedback de arquivos CSV", + "source_connect_formbricks_description": "Conectar feedback das suas pesquisas Formbricks", + "source_fields": "Campos de origem", + "source_name": "Nome da origem", + "source_type_cannot_be_changed": "O tipo de origem não pode ser alterado", + "sources": "Origens", + "status_active": "Ativa", + "status_completed": "Concluído", + "status_draft": "Rascunho", + "status_error": "Erro", + "status_paused": "Pausado", + "survey_has_no_elements": "Esta pesquisa não possui elementos de pergunta", + "test_connection": "Testar conexão", + "unify_feedback": "Unificar feedback", + "update_mapping_description": "Atualize a configuração de mapeamento para esta fonte.", + "upload_csv_data_description": "Faça upload de um arquivo CSV ou configure importações automatizadas do S3.", + "upload_csv_file": "Fazer upload de arquivo CSV", + "view_setup_guide": "Ver guia de configuração →", + "yes_delete": "Sim, deletar" + }, "workspace": { "api_keys": { "add_api_key": "Adicionar chave de API", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index c09acd233f..68665c0608 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -141,6 +141,7 @@ "app_survey": "Inquérito (app)", "apply_filters": "Aplicar filtros", "are_you_sure": "Tem a certeza?", + "ask": "Perguntar", "attributes": "Atributos", "back": "Voltar", "billing": "Faturação", @@ -163,7 +164,7 @@ "code": "Código", "collapse_rows": "Recolher linhas", "completed": "Concluído", - "configuration": "Configuração", + "configure": "Configurar", "confirm": "Confirmar", "connect": "Conectar", "connect_formbricks": "Ligar Formbricks", @@ -199,6 +200,7 @@ "disallow": "Não permitir", "discard": "Descartar", "dismissed": "Dispensado", + "distribute": "Distribuir", "docs": "Documentação", "documentation": "Documentação", "domain": "Domínio", @@ -267,6 +269,7 @@ "logout": "Terminar sessão", "look_and_feel": "Aparência e Sensação", "manage": "Gerir", + "mappings": "Mapeamentos", "marketing": "Marketing", "member": "Membro", "members": "Membros", @@ -428,6 +431,7 @@ "top_right": "Superior Direito", "try_again": "Tente novamente", "type": "Tipo", + "unify": "Unificar", "unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.", "update": "Atualizar", "updated": "Atualizado", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "Este questionário usa lógica de ramificação." } }, + "unify": { + "add_feedback_source": "Adicionar fonte de feedback", + "add_source": "Adicionar fonte", + "are_you_sure": "Tem a certeza?", + "automated": "Automatizado", + "aws_region": "Região AWS", + "change_file": "Alterar ficheiro", + "click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas", + "click_to_upload": "Clique para carregar", + "configure_import": "Configurar importação", + "configure_mapping": "Configurar mapeamento", + "connection": "Conexão", + "connector_created_successfully": "Conector criado com sucesso", + "connector_deleted_successfully": "Conector eliminado com sucesso", + "connector_updated_successfully": "Conector atualizado com sucesso", + "copied": "Copiado!", + "copy": "Copiar", + "create_mapping": "Criar mapeamento", + "csv_at_least_one_row": "O CSV deve conter pelo menos uma linha de dados.", + "csv_columns": "Colunas CSV", + "csv_empty_column_headers": "O CSV contém cabeçalhos de coluna vazios. Todas as colunas devem ter um nome.", + "csv_file_too_large": "O ficheiro CSV é demasiado grande. O tamanho máximo é 2MB.", + "csv_files_only": "Apenas ficheiros CSV", + "csv_import": "Importação CSV", + "csv_inconsistent_columns": "A linha {{row}} tem colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.", + "csv_max_records": "Máximo de {{max}} registos permitidos.", + "default_connector_name_csv": "Importação CSV", + "default_connector_name_formbricks": "Conexão de pesquisa Formbricks", + "delete_source": "Eliminar fonte", + "deselect_all": "Desselecionar tudo", + "drop_a_field_here": "Solte um campo aqui", + "drop_field_or": "Solte o campo ou", + "drop_zone_path": "Caminho da zona de soltar", + "edit_source_connection": "Editar ligação de origem", + "element_selected": "{count} elemento selecionado. Cada resposta a este elemento criará um FeedbackRecord no Hub.", + "elements_selected": "{count} elementos selecionados. Cada resposta a estes elementos criará um FeedbackRecord no Hub.", + "enable_auto_sync": "Ativar sincronização automática", + "enter_name_for_source": "Introduz um nome para esta origem", + "enter_value": "Introduzir valor...", + "enum": "enum", + "every_15_minutes": "A cada 15 minutos", + "every_30_minutes": "A cada 30 minutos", + "every_5_minutes": "A cada 5 minutos", + "every_hour": "A cada hora", + "feedback_date": "Data do feedback", + "field": "campo", + "fields": "campos", + "formbricks_surveys": "Pesquisas Formbricks", + "hub_feedback_record_fields": "Campos de registo de feedback do Hub", + "iam_configuration_required": "Configuração IAM necessária", + "iam_setup_instructions": "Adiciona a função IAM do Formbricks à política do teu bucket S3 para ativar o acesso.", + "import_csv_data": "Importar dados CSV", + "load_sample_csv": "Carregar CSV de exemplo", + "n_elements": "{count} elementos", + "no_source_fields_loaded": "Ainda não foram carregados campos de origem", + "no_sources_connected": "Ainda não há origens ligadas. Adicione uma origem para começar.", + "no_surveys_found": "Nenhum inquérito encontrado neste ambiente", + "optional": "Opcional", + "or": "ou", + "or_drag_and_drop": "ou arraste e largue", + "process_new_files_description": "Processar automaticamente novos ficheiros colocados no bucket", + "processing_interval": "Intervalo de processamento", + "region_ap_southeast_1": "Ásia-Pacífico (Singapura)", + "region_eu_central_1": "UE (Frankfurt)", + "region_eu_west_1": "UE (Irlanda)", + "region_us_east_1": "EUA Leste (N. Virgínia)", + "region_us_west_2": "EUA Oeste (Oregon)", + "required": "Obrigatório", + "s3_bucket_description": "Coloque ficheiros CSV no seu bucket S3 para importar automaticamente feedback. Os ficheiros são processados a cada 15 minutos.", + "s3_bucket_integration": "Integração com bucket S3", + "save_changes": "Guardar alterações", + "select_a_survey_to_see_elements": "Selecione um inquérito para ver os seus elementos", + "select_a_value": "Selecione um valor...", + "select_all": "Selecionar tudo", + "select_elements": "Selecionar elementos", + "select_questions": "Selecionar perguntas", + "select_source_type_description": "Selecione o tipo de fonte de feedback que pretende conectar.", + "select_source_type_prompt": "Selecione o tipo de fonte de feedback que pretende conectar:", + "select_survey": "Selecionar inquérito", + "select_survey_and_questions": "Selecionar inquérito e perguntas", + "select_survey_questions_description": "Escolha quais perguntas do inquérito devem criar FeedbackRecords.", + "set_value": "definir valor", + "setup_connection": "Configurar ligação", + "showing_rows": "A mostrar 3 de {count} linhas", + "source_connect_csv_description": "Importar feedback de ficheiros CSV", + "source_connect_formbricks_description": "Conectar feedback dos seus inquéritos Formbricks", + "source_fields": "Campos da fonte", + "source_name": "Nome da fonte", + "source_type_cannot_be_changed": "O tipo de fonte não pode ser alterado", + "sources": "Fontes", + "status_active": "Ativa", + "status_completed": "Concluído", + "status_draft": "Rascunho", + "status_error": "Erro", + "status_paused": "Em pausa", + "survey_has_no_elements": "Este inquérito não tem elementos de pergunta", + "test_connection": "Testar ligação", + "unify_feedback": "Unificar feedback", + "update_mapping_description": "Atualiza a configuração de mapeamento para esta origem.", + "upload_csv_data_description": "Carrega um ficheiro CSV ou configura importações automáticas do S3.", + "upload_csv_file": "Carregar ficheiro CSV", + "view_setup_guide": "Ver guia de configuração →", + "yes_delete": "Sim, eliminar" + }, "workspace": { "api_keys": { "add_api_key": "Adicionar chave API", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index ad8f4ba384..69b82f1876 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -141,6 +141,7 @@ "app_survey": "Sondaj aplicație", "apply_filters": "Aplică filtre", "are_you_sure": "Ești sigur?", + "ask": "Întreabă", "attributes": "Atribute", "back": "Înapoi", "billing": "Facturare", @@ -163,7 +164,7 @@ "code": "Cod", "collapse_rows": "Restrânge rânduri", "completed": "Completat", - "configuration": "Configurare", + "configure": "Configurează", "confirm": "Confirmare", "connect": "Conectează", "connect_formbricks": "Conectează Formbricks", @@ -199,6 +200,7 @@ "disallow": "Nu permite", "discard": "Renunță", "dismissed": "Respins", + "distribute": "Distribuie", "docs": "Documentație", "documentation": "Documentație", "domain": "Domeniu", @@ -267,6 +269,7 @@ "logout": "Deconectare", "look_and_feel": "Aspect și Comportament", "manage": "Gestionați", + "mappings": "Mapări", "marketing": "Marketing", "member": "Membru", "members": "Membri", @@ -428,6 +431,7 @@ "top_right": "Dreapta Sus", "try_again": "Încearcă din nou", "type": "Tip", + "unify": "Unifică", "unlock_more_workspaces_with_a_higher_plan": "Deblochează mai multe workspaces cu un plan superior.", "update": "Actualizare", "updated": "Actualizat", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "Acest sondaj folosește logică de ramificare." } }, + "unify": { + "add_feedback_source": "Adaugă sursă de feedback", + "add_source": "Adaugă sursă", + "are_you_sure": "Ești sigur?", + "automated": "Automatizat", + "aws_region": "Regiune AWS", + "change_file": "Schimbă fișierul", + "click_load_sample_csv": "Apasă pe „Încarcă CSV de exemplu” pentru a vedea coloanele", + "click_to_upload": "Apasă pentru a încărca", + "configure_import": "Configurează importul", + "configure_mapping": "Configurează maparea", + "connection": "Conexiune", + "connector_created_successfully": "Conector creat cu succes", + "connector_deleted_successfully": "Conector șters cu succes", + "connector_updated_successfully": "Conector actualizat cu succes", + "copied": "Copiat!", + "copy": "Copiază", + "create_mapping": "Creează mapare", + "csv_at_least_one_row": "CSV-ul trebuie să conțină cel puțin un rând de date.", + "csv_columns": "Coloane CSV", + "csv_empty_column_headers": "CSV-ul conține antete de coloană goale. Toate coloanele trebuie să aibă un nume.", + "csv_file_too_large": "Fișierul CSV este prea mare. Dimensiunea maximă este de 2 MB.", + "csv_files_only": "Doar fișiere CSV", + "csv_import": "Import CSV", + "csv_inconsistent_columns": "Rândul {{row}} are coloane inconsistente. Toate rândurile trebuie să aibă aceleași antete.", + "csv_max_records": "Sunt permise maximum {{max}} înregistrări.", + "default_connector_name_csv": "Import CSV", + "default_connector_name_formbricks": "Conexiune chestionar Formbricks", + "delete_source": "Șterge sursa", + "deselect_all": "Deselectează tot", + "drop_a_field_here": "Trage un câmp aici", + "drop_field_or": "Trage câmpul sau", + "drop_zone_path": "Cale zonă de plasare", + "edit_source_connection": "Editează conexiunea sursei", + "element_selected": "{count} element selectat. Fiecare răspuns la aceste elemente va crea un FeedbackRecord în Hub.", + "elements_selected": "{count} elemente selectate. Fiecare răspuns la aceste elemente va crea un FeedbackRecord în Hub.", + "enable_auto_sync": "Activează auto-sync", + "enter_name_for_source": "Introdu un nume pentru această sursă", + "enter_value": "Introdu valoarea...", + "enum": "enum", + "every_15_minutes": "La fiecare 15 minute", + "every_30_minutes": "La fiecare 30 de minute", + "every_5_minutes": "La fiecare 5 minute", + "every_hour": "La fiecare oră", + "feedback_date": "Data feedbackului", + "field": "câmp", + "fields": "câmpuri", + "formbricks_surveys": "Chestionare Formbricks", + "hub_feedback_record_fields": "Câmpuri FeedbackRecord din Hub", + "iam_configuration_required": "Configurare IAM necesară", + "iam_setup_instructions": "Adaugă rolul Formbricks IAM în politica bucket-ului tău S3 pentru a permite accesul.", + "import_csv_data": "Importă date CSV", + "load_sample_csv": "Încarcă un CSV de exemplu", + "n_elements": "{count} elemente", + "no_source_fields_loaded": "Nu au fost încă încărcate câmpuri sursă", + "no_sources_connected": "Nicio sursă conectată încă. Adaugă o sursă pentru a începe.", + "no_surveys_found": "Nu s-au găsit sondaje în acest mediu", + "optional": "Opțional", + "or": "sau", + "or_drag_and_drop": "sau trage și lasă aici", + "process_new_files_description": "Procesează automat fișierele noi adăugate în bucket", + "processing_interval": "Interval de procesare", + "region_ap_southeast_1": "Asia Pacific (Singapore)", + "region_eu_central_1": "UE (Frankfurt)", + "region_eu_west_1": "UE (Irlanda)", + "region_us_east_1": "SUA Est (N. Virginia)", + "region_us_west_2": "SUA Vest (Oregon)", + "required": "Obligatoriu", + "s3_bucket_description": "Adaugă fișiere CSV în bucket-ul tău S3 pentru a importa automat feedback-ul. Fișierele sunt procesate la fiecare 15 minute.", + "s3_bucket_integration": "Integrare S3 Bucket", + "save_changes": "Salvează modificările", + "select_a_survey_to_see_elements": "Selectează un sondaj pentru a vedea elementele", + "select_a_value": "Selectează o valoare...", + "select_all": "Selectează tot", + "select_elements": "Selectează elemente", + "select_questions": "Selectează întrebări", + "select_source_type_description": "Selectează tipul sursei de feedback pe care vrei să o conectezi.", + "select_source_type_prompt": "Selectează tipul sursei de feedback pe care vrei să o conectezi:", + "select_survey": "Selectează chestionar", + "select_survey_and_questions": "Selectează chestionar și întrebări", + "select_survey_questions_description": "Alege ce întrebări din chestionar vor crea FeedbackRecords.", + "set_value": "setează valoare", + "setup_connection": "Configurează conexiunea", + "showing_rows": "Se afișează 3 din {count} rânduri", + "source_connect_csv_description": "Importă feedback din fișiere CSV", + "source_connect_formbricks_description": "Conectează feedback din sondajele Formbricks", + "source_fields": "Câmpuri sursă", + "source_name": "Nume sursă", + "source_type_cannot_be_changed": "Tipul sursei nu poate fi schimbat", + "sources": "Surse", + "status_active": "Activ", + "status_completed": "Finalizat", + "status_draft": "Schiță", + "status_error": "Eroare", + "status_paused": "Pauzat", + "survey_has_no_elements": "Acest chestionar nu are elemente de întrebare", + "test_connection": "Testează conexiunea", + "unify_feedback": "Unify Feedback", + "update_mapping_description": "Actualizează configurația de mapare pentru această sursă.", + "upload_csv_data_description": "Încarcă un fișier CSV sau configurează importuri automate din S3.", + "upload_csv_file": "Încarcă fișier CSV", + "view_setup_guide": "Vezi ghidul de configurare →", + "yes_delete": "Da, șterge" + }, "workspace": { "api_keys": { "add_api_key": "Adaugă cheie API", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 2ddcb2cf86..ed19400b36 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -141,6 +141,7 @@ "app_survey": "Опрос о приложении", "apply_filters": "Применить фильтры", "are_you_sure": "Вы уверены?", + "ask": "Спросить", "attributes": "Атрибуты", "back": "Назад", "billing": "Оплата", @@ -163,7 +164,7 @@ "code": "Код", "collapse_rows": "Свернуть строки", "completed": "Завершено", - "configuration": "Конфигурация", + "configure": "Настроить", "confirm": "Подтвердить", "connect": "Подключить", "connect_formbricks": "Подключить Formbricks", @@ -199,6 +200,7 @@ "disallow": "Не разрешать", "discard": "Отменить", "dismissed": "Отклонено", + "distribute": "Распределить", "docs": "Документация", "documentation": "Документация", "domain": "Домен", @@ -267,6 +269,7 @@ "logout": "Выйти", "look_and_feel": "Внешний вид", "manage": "Управление", + "mappings": "Сопоставления", "marketing": "Маркетинг", "member": "Участник", "members": "Участники", @@ -428,6 +431,7 @@ "top_right": "Вверху справа", "try_again": "Попробуйте ещё раз", "type": "Тип", + "unify": "Объединить", "unlock_more_workspaces_with_a_higher_plan": "Откройте больше рабочих пространств с более высоким тарифом.", "update": "Обновить", "updated": "Обновлено", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "В этом опросе используется разветвлённая логика." } }, + "unify": { + "add_feedback_source": "Добавить источник отзывов", + "add_source": "Добавить источник", + "are_you_sure": "Вы уверены?", + "automated": "Автоматически", + "aws_region": "Регион AWS", + "change_file": "Изменить файл", + "click_load_sample_csv": "Нажмите «Загрузить пример CSV», чтобы увидеть столбцы", + "click_to_upload": "Кликните для загрузки", + "configure_import": "Настроить импорт", + "configure_mapping": "Настроить сопоставление", + "connection": "Подключение", + "connector_created_successfully": "Коннектор успешно создан", + "connector_deleted_successfully": "Коннектор успешно удалён", + "connector_updated_successfully": "Коннектор успешно обновлён", + "copied": "Скопировано!", + "copy": "Копировать", + "create_mapping": "Создать сопоставление", + "csv_at_least_one_row": "CSV должен содержать хотя бы одну строку с данными.", + "csv_columns": "Столбцы CSV", + "csv_empty_column_headers": "В CSV есть пустые заголовки столбцов. У всех столбцов должно быть имя.", + "csv_file_too_large": "Файл CSV слишком большой. Максимальный размер — 2 МБ.", + "csv_files_only": "Только файлы CSV", + "csv_import": "Импорт CSV", + "csv_inconsistent_columns": "В строке {{row}} несоответствие столбцов. Во всех строках должны быть одинаковые заголовки.", + "csv_max_records": "Допустимо не более {{max}} записей.", + "default_connector_name_csv": "Импорт CSV", + "default_connector_name_formbricks": "Подключение опроса Formbricks", + "delete_source": "Удалить источник", + "deselect_all": "Снять выделение со всех", + "drop_a_field_here": "Перетащи сюда поле", + "drop_field_or": "Перетащи поле или", + "drop_zone_path": "Путь зоны сброса", + "edit_source_connection": "Редактировать подключение источника", + "element_selected": "{count} элемент выбран. Каждый ответ на эти элементы создаст FeedbackRecord в Hub.", + "elements_selected": "{count} элементов выбрано. Каждый ответ на эти элементы создаст FeedbackRecord в Hub.", + "enable_auto_sync": "Включить авто-синхронизацию", + "enter_name_for_source": "Введи имя для этого источника", + "enter_value": "Введите значение...", + "enum": "enum", + "every_15_minutes": "Каждые 15 минут", + "every_30_minutes": "Каждые 30 минут", + "every_5_minutes": "Каждые 5 минут", + "every_hour": "Каждый час", + "feedback_date": "Дата отзыва", + "field": "поле", + "fields": "поля", + "formbricks_surveys": "Formbricks Surveys", + "hub_feedback_record_fields": "Поля FeedbackRecord в Hub", + "iam_configuration_required": "Требуется настройка IAM", + "iam_setup_instructions": "Добавь роль Formbricks IAM в политику своего S3-бакета для предоставления доступа.", + "import_csv_data": "Импортировать данные CSV", + "load_sample_csv": "Загрузить пример CSV", + "n_elements": "{count} элементов", + "no_source_fields_loaded": "Поля источника ещё не загружены", + "no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.", + "no_surveys_found": "В этой среде не найдено опросов", + "optional": "Необязательно", + "or": "или", + "or_drag_and_drop": "или перетащите файл", + "process_new_files_description": "Автоматически обрабатывать новые файлы, добавленные в бакет", + "processing_interval": "Интервал обработки", + "region_ap_southeast_1": "Азиатско-Тихоокеанский регион (Сингапур)", + "region_eu_central_1": "ЕС (Франкфурт)", + "region_eu_west_1": "ЕС (Ирландия)", + "region_us_east_1": "США Восток (Северная Вирджиния)", + "region_us_west_2": "США Запад (Орегон)", + "required": "Обязательно", + "s3_bucket_description": "Перемещайте файлы CSV в свой S3-бакет для автоматического импорта отзывов. Файлы обрабатываются каждые 15 минут.", + "s3_bucket_integration": "Интеграция с S3-бакетом", + "save_changes": "Сохранить изменения", + "select_a_survey_to_see_elements": "Выберите опрос, чтобы увидеть его элементы", + "select_a_value": "Выберите значение...", + "select_all": "Выбрать все", + "select_elements": "Выбрать элементы", + "select_questions": "Выбрать вопросы", + "select_source_type_description": "Выберите тип источника отзывов, который хотите подключить.", + "select_source_type_prompt": "Выберите тип источника отзывов, который хотите подключить:", + "select_survey": "Выбрать опрос", + "select_survey_and_questions": "Выбрать опрос и вопросы", + "select_survey_questions_description": "Выберите, какие вопросы опроса должны создавать FeedbackRecords.", + "set_value": "установить значение", + "setup_connection": "Настроить подключение", + "showing_rows": "Показано 3 из {count} строк", + "source_connect_csv_description": "Импортировать отзывы из CSV-файлов", + "source_connect_formbricks_description": "Подключить отзывы из ваших опросов Formbricks", + "source_fields": "Поля источника", + "source_name": "Имя источника", + "source_type_cannot_be_changed": "Тип источника нельзя изменить", + "sources": "Источники", + "status_active": "Активен", + "status_completed": "Завершён", + "status_draft": "Черновик", + "status_error": "Ошибка", + "status_paused": "Приостановлен", + "survey_has_no_elements": "В этом опросе нет вопросов", + "test_connection": "Проверить подключение", + "unify_feedback": "Обратная связь Unify", + "update_mapping_description": "Обнови настройки сопоставления для этого источника.", + "upload_csv_data_description": "Загрузи CSV-файл или настрой автоматический импорт из S3.", + "upload_csv_file": "Загрузить CSV-файл", + "view_setup_guide": "Посмотреть инструкцию по настройке →", + "yes_delete": "Да, удалить" + }, "workspace": { "api_keys": { "add_api_key": "Добавить API-ключ", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 0c29b1aa9f..9e46902a4a 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -141,6 +141,7 @@ "app_survey": "App-enkät", "apply_filters": "Tillämpa filter", "are_you_sure": "Är du säker?", + "ask": "Fråga", "attributes": "Attribut", "back": "Tillbaka", "billing": "Fakturering", @@ -163,7 +164,7 @@ "code": "Kod", "collapse_rows": "Dölj rader", "completed": "Slutförd", - "configuration": "Konfiguration", + "configure": "Konfigurera", "confirm": "Bekräfta", "connect": "Anslut", "connect_formbricks": "Anslut Formbricks", @@ -199,6 +200,7 @@ "disallow": "Tillåt inte", "discard": "Förkasta", "dismissed": "Avvisad", + "distribute": "Dela ut", "docs": "Dokumentation", "documentation": "Dokumentation", "domain": "Domän", @@ -267,6 +269,7 @@ "logout": "Logga ut", "look_and_feel": "Utseende", "manage": "Hantera", + "mappings": "Mappningar", "marketing": "Marknadsföring", "member": "Medlem", "members": "Medlemmar", @@ -428,6 +431,7 @@ "top_right": "Övre höger", "try_again": "Försök igen", "type": "Typ", + "unify": "Förenas", "unlock_more_workspaces_with_a_higher_plan": "Lås upp fler arbetsytor med ett högre abonnemang.", "update": "Uppdatera", "updated": "Uppdaterad", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "Denna enkät använder förgreningslogik." } }, + "unify": { + "add_feedback_source": "Lägg till feedbackkälla", + "add_source": "Lägg till källa", + "are_you_sure": "Är du säker?", + "automated": "Automatiserad", + "aws_region": "AWS-region", + "change_file": "Byt fil", + "click_load_sample_csv": "Klicka på 'Ladda exempel-CSV' för att se kolumner", + "click_to_upload": "Klicka för att ladda upp", + "configure_import": "Konfigurera import", + "configure_mapping": "Konfigurera mappning", + "connection": "Anslutning", + "connector_created_successfully": "Kopplingen skapades", + "connector_deleted_successfully": "Kopplingen togs bort", + "connector_updated_successfully": "Kopplingen uppdaterades", + "copied": "Kopierat!", + "copy": "Kopiera", + "create_mapping": "Skapa mappning", + "csv_at_least_one_row": "CSV-filen måste innehålla minst en datarad.", + "csv_columns": "CSV-kolumner", + "csv_empty_column_headers": "CSV-filen innehåller tomma kolumnrubriker. Alla kolumner måste ha ett namn.", + "csv_file_too_large": "CSV-filen är för stor. Maxstorlek är 2 MB.", + "csv_files_only": "Endast CSV-filer", + "csv_import": "CSV-import", + "csv_inconsistent_columns": "Rad {{row}} har inkonsekventa kolumner. Alla rader måste ha samma rubriker.", + "csv_max_records": "Maximalt {{max}} poster tillåtna.", + "default_connector_name_csv": "CSV-import", + "default_connector_name_formbricks": "Formbricks Survey-anslutning", + "delete_source": "Ta bort källa", + "deselect_all": "Avmarkera alla", + "drop_a_field_here": "Släpp ett fält här", + "drop_field_or": "Släpp fält eller", + "drop_zone_path": "Släppzonens sökväg", + "edit_source_connection": "Redigera källans anslutning", + "element_selected": "{count} element vald. Varje svar på dessa element skapar en FeedbackRecord i Hubben.", + "elements_selected": "{count} element valda. Varje svar på dessa element skapar en FeedbackRecord i Hubben.", + "enable_auto_sync": "Aktivera auto-sync", + "enter_name_for_source": "Ange ett namn för denna källa", + "enter_value": "Ange värde...", + "enum": "enum", + "every_15_minutes": "Var 15:e minut", + "every_30_minutes": "Var 30:e minut", + "every_5_minutes": "Var 5:e minut", + "every_hour": "Varje timme", + "feedback_date": "Feedbackdatum", + "field": "fält", + "fields": "fält", + "formbricks_surveys": "Formbricks Surveys", + "hub_feedback_record_fields": "Fält för Hub Feedback Record", + "iam_configuration_required": "IAM-konfiguration krävs", + "iam_setup_instructions": "Lägg till Formbricks IAM-roll i din S3-bucketpolicy för att aktivera åtkomst.", + "import_csv_data": "Importera CSV-data", + "load_sample_csv": "Ladda exempel-CSV", + "n_elements": "{count} element", + "no_source_fields_loaded": "Inga källfält har laddats än", + "no_sources_connected": "Inga källor är anslutna än. Lägg till en källa för att komma igång.", + "no_surveys_found": "Inga enkäter hittades i denna miljö", + "optional": "Valfritt", + "or": "eller", + "or_drag_and_drop": "eller dra och släpp", + "process_new_files_description": "Bearbeta nya filer som släpps i bucketen automatiskt", + "processing_interval": "Bearbetningsintervall", + "region_ap_southeast_1": "Asien och Stillahavsområdet (Singapore)", + "region_eu_central_1": "EU (Frankfurt)", + "region_eu_west_1": "EU (Irland)", + "region_us_east_1": "USA Öst (N. Virginia)", + "region_us_west_2": "USA Väst (Oregon)", + "required": "Obligatoriskt", + "s3_bucket_description": "Släpp CSV-filer i din S3-bucket för att automatiskt importera feedback. Filer bearbetas var 15:e minut.", + "s3_bucket_integration": "S3-bucket-integration", + "save_changes": "Spara ändringar", + "select_a_survey_to_see_elements": "Välj en enkät för att se dess element", + "select_a_value": "Välj ett värde...", + "select_all": "Välj alla", + "select_elements": "Välj element", + "select_questions": "Välj frågor", + "select_source_type_description": "Välj vilken typ av feedbackkälla du vill ansluta.", + "select_source_type_prompt": "Välj vilken typ av feedbackkälla du vill ansluta:", + "select_survey": "Välj enkät", + "select_survey_and_questions": "Välj enkät & frågor", + "select_survey_questions_description": "Välj vilka enkätfrågor som ska skapa FeedbackRecords.", + "set_value": "ange värde", + "setup_connection": "Ställ in anslutning", + "showing_rows": "Visar 3 av {count} rader", + "source_connect_csv_description": "Importera feedback från CSV-filer", + "source_connect_formbricks_description": "Anslut feedback från dina Formbricks-enkäter", + "source_fields": "Källfält", + "source_name": "Källnamn", + "source_type_cannot_be_changed": "Källtyp kan inte ändras", + "sources": "Källor", + "status_active": "Aktiv", + "status_completed": "Slutförd", + "status_draft": "Utkast", + "status_error": "Fel", + "status_paused": "Pausad", + "survey_has_no_elements": "Den här enkäten har inga frågeelement", + "test_connection": "Testa anslutning", + "unify_feedback": "Samla feedback", + "update_mapping_description": "Uppdatera mappningskonfigurationen för den här källan.", + "upload_csv_data_description": "Ladda upp en CSV-fil eller ställ in automatiska S3-importer.", + "upload_csv_file": "Ladda upp CSV-fil", + "view_setup_guide": "Visa installationsguide →", + "yes_delete": "Ja, ta bort" + }, "workspace": { "api_keys": { "add_api_key": "Lägg till API-nyckel", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 7b4ff2f65f..8bcb2dace9 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -141,6 +141,7 @@ "app_survey": "应用 程序 调查", "apply_filters": "应用 筛选", "are_you_sure": "你 确定 吗?", + "ask": "提问", "attributes": "属性", "back": "返回", "billing": "账单", @@ -163,7 +164,7 @@ "code": "代码", "collapse_rows": "折叠 行", "completed": "完成", - "configuration": "配置", + "configure": "配置", "confirm": "确认", "connect": "连接", "connect_formbricks": "连接 Formbricks", @@ -199,6 +200,7 @@ "disallow": "不允许", "discard": "丢弃", "dismissed": "忽略", + "distribute": "分发", "docs": "文档", "documentation": "文档", "domain": "域名", @@ -267,6 +269,7 @@ "logout": "退出登录", "look_and_feel": "外观 & 感觉", "manage": "管理", + "mappings": "映射", "marketing": "市场营销", "member": "成员", "members": "成员", @@ -428,6 +431,7 @@ "top_right": "右上", "try_again": "再试一次", "type": "类型", + "unify": "统一", "unlock_more_workspaces_with_a_higher_plan": "升级套餐以解锁更多工作区。", "update": "更新", "updated": "已更新", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "此调查 使用 分支逻辑。" } }, + "unify": { + "add_feedback_source": "添加反馈来源", + "add_source": "添加来源", + "are_you_sure": "你确定吗?", + "automated": "自动化", + "aws_region": "AWS 区域", + "change_file": "更换文件", + "click_load_sample_csv": "点击“加载示例 CSV”查看列", + "click_to_upload": "点击上传", + "configure_import": "配置导入", + "configure_mapping": "配置映射", + "connection": "连接", + "connector_created_successfully": "连接器创建成功", + "connector_deleted_successfully": "连接器删除成功", + "connector_updated_successfully": "连接器更新成功", + "copied": "已复制!", + "copy": "复制", + "create_mapping": "创建映射", + "csv_at_least_one_row": "CSV 文件中至少要有一行数据。", + "csv_columns": "CSV 列", + "csv_empty_column_headers": "CSV 文件包含空的列标题。所有列都必须有名称。", + "csv_file_too_large": "CSV 文件过大,最大支持 2MB。", + "csv_files_only": "仅限 CSV 文件", + "csv_import": "CSV 导入", + "csv_inconsistent_columns": "第 {{row}} 行的列数不一致。所有行必须有相同的标题。", + "csv_max_records": "最多允许 {{max}} 条记录。", + "default_connector_name_csv": "CSV 导入", + "default_connector_name_formbricks": "Formbricks 调查连接", + "delete_source": "删除来源", + "deselect_all": "取消全选", + "drop_a_field_here": "将字段拖到这里", + "drop_field_or": "拖放字段或", + "drop_zone_path": "拖放区域路径", + "edit_source_connection": "编辑源连接", + "element_selected": "已选择 {count} 个元素。每个元素的反馈都会在 Hub 中创建一个 FeedbackRecord。", + "elements_selected": "已选择 {count} 个元素。每个元素的反馈都会在 Hub 中创建一个 FeedbackRecord。", + "enable_auto_sync": "启用 auto 同步", + "enter_name_for_source": "为此来源输入名称", + "enter_value": "请输入值...", + "enum": "枚举", + "every_15_minutes": "每 15 分钟", + "every_30_minutes": "每 30 分钟", + "every_5_minutes": "每 5 分钟", + "every_hour": "每小时", + "feedback_date": "反馈日期", + "field": "字段", + "fields": "字段", + "formbricks_surveys": "Formbricks Surveys", + "hub_feedback_record_fields": "Hub 反馈记录字段", + "iam_configuration_required": "需要 IAM 配置", + "iam_setup_instructions": "将 Formbricks IAM 角色添加到你的 S3 bucket 策略中以启用访问权限。", + "import_csv_data": "导入 CSV 数据", + "load_sample_csv": "加载示例 CSV", + "n_elements": "{count} 个元素", + "no_source_fields_loaded": "尚未加载源字段", + "no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。", + "no_surveys_found": "此环境下未找到调查", + "optional": "可选", + "or": "或", + "or_drag_and_drop": "或拖放", + "process_new_files_description": "自动处理存储桶中新上传的文件", + "processing_interval": "处理间隔", + "region_ap_southeast_1": "亚太地区(新加坡)", + "region_eu_central_1": "欧盟(法兰克福)", + "region_eu_west_1": "欧盟(爱尔兰)", + "region_us_east_1": "美国东部(弗吉尼亚北部)", + "region_us_west_2": "美国西部(俄勒冈)", + "required": "必填", + "s3_bucket_description": "将 CSV 文件放入你的 S3 存储桶,即可自动导入反馈。文件每 15 分钟处理一次。", + "s3_bucket_integration": "S3 存储桶集成", + "save_changes": "保存更改", + "select_a_survey_to_see_elements": "选择一个调查以查看其元素", + "select_a_value": "选择一个值...", + "select_all": "全选", + "select_elements": "选择元素", + "select_questions": "选择问题", + "select_source_type_description": "请选择你想要连接的反馈来源类型。", + "select_source_type_prompt": "请选择你想要连接的反馈来源类型:", + "select_survey": "选择调查", + "select_survey_and_questions": "选择调查和问题", + "select_survey_questions_description": "选择哪些调查问题会创建反馈记录。", + "set_value": "设置值", + "setup_connection": "设置连接", + "showing_rows": "显示 {count} 行中的 3 行", + "source_connect_csv_description": "从 CSV 文件导入反馈", + "source_connect_formbricks_description": "连接来自你 Formbricks 调查的反馈", + "source_fields": "来源字段", + "source_name": "来源名称", + "source_type_cannot_be_changed": "来源类型无法更改", + "sources": "来源", + "status_active": "已激活", + "status_completed": "已完成", + "status_draft": "草稿", + "status_error": "错误", + "status_paused": "已暂停", + "survey_has_no_elements": "此调查没有问题元素", + "test_connection": "测试连接", + "unify_feedback": "统一反馈", + "update_mapping_description": "更新此来源的映射配置。", + "upload_csv_data_description": "上传 CSV 文件或设置自动 S3 导入。", + "upload_csv_file": "上传 CSV 文件", + "view_setup_guide": "查看设置指南 →", + "yes_delete": "是的,删除" + }, "workspace": { "api_keys": { "add_api_key": "添加 API 密钥", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 7893c8f093..f167382f85 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -141,6 +141,7 @@ "app_survey": "應用程式問卷", "apply_filters": "套用篩選器", "are_you_sure": "您確定嗎?", + "ask": "提問", "attributes": "屬性", "back": "返回", "billing": "帳單", @@ -163,7 +164,7 @@ "code": "程式碼", "collapse_rows": "摺疊列", "completed": "已完成", - "configuration": "組態", + "configure": "設定", "confirm": "確認", "connect": "連線", "connect_formbricks": "連線 Formbricks", @@ -199,6 +200,7 @@ "disallow": "不允許", "discard": "捨棄", "dismissed": "已關閉", + "distribute": "分發", "docs": "文件", "documentation": "文件", "domain": "網域", @@ -267,6 +269,7 @@ "logout": "登出", "look_and_feel": "外觀與風格", "manage": "管理", + "mappings": "對應關係", "marketing": "行銷", "member": "成員", "members": "成員", @@ -428,6 +431,7 @@ "top_right": "右上", "try_again": "再試一次", "type": "類型", + "unify": "統整", "unlock_more_workspaces_with_a_higher_plan": "升級方案以解鎖更多工作區。", "update": "更新", "updated": "已更新", @@ -2035,6 +2039,110 @@ "uses_branching_logic": "此問卷使用分支邏輯。" } }, + "unify": { + "add_feedback_source": "新增回饋來源", + "add_source": "新增來源", + "are_you_sure": "您確定嗎?", + "automated": "自動化", + "aws_region": "AWS 區域", + "change_file": "更換檔案", + "click_load_sample_csv": "點擊「載入範例 CSV」以查看欄位", + "click_to_upload": "點擊以上傳", + "configure_import": "設定匯入", + "configure_mapping": "設定對應關係", + "connection": "連線", + "connector_created_successfully": "連接器建立成功", + "connector_deleted_successfully": "連接器刪除成功", + "connector_updated_successfully": "連接器更新成功", + "copied": "已複製!", + "copy": "複製", + "create_mapping": "建立對應關係", + "csv_at_least_one_row": "CSV 必須至少包含一筆資料列。", + "csv_columns": "CSV 欄位", + "csv_empty_column_headers": "CSV 包含空白的欄位標題。所有欄位都必須有名稱。", + "csv_file_too_large": "CSV 檔案過大,最大限制為 2MB。", + "csv_files_only": "僅限 CSV 檔案", + "csv_import": "CSV 匯入", + "csv_inconsistent_columns": "第 {{row}} 列的欄位數不一致。所有資料列必須有相同的標題。", + "csv_max_records": "最多允許 {{max}} 筆紀錄。", + "default_connector_name_csv": "CSV 匯入", + "default_connector_name_formbricks": "Formbricks 問卷連線", + "delete_source": "刪除來源", + "deselect_all": "取消全選", + "drop_a_field_here": "請將欄位拖曳到這裡", + "drop_field_or": "拖曳欄位或", + "drop_zone_path": "拖曳區路徑", + "edit_source_connection": "編輯來源連線", + "element_selected": "已選取 {count} 個元素。每個對這些元素的回應都會在 Hub 中建立一個 FeedbackRecord。", + "elements_selected": "已選取 {count} 個元素。每個對這些元素的回應都會在 Hub 中建立一個 FeedbackRecord。", + "enable_auto_sync": "啟用 auto 同步", + "enter_name_for_source": "請輸入此來源的名稱", + "enter_value": "請輸入值……", + "enum": "enum", + "every_15_minutes": "每 15 分鐘", + "every_30_minutes": "每 30 分鐘", + "every_5_minutes": "每 5 分鐘", + "every_hour": "每小時", + "feedback_date": "回饋日期", + "field": "欄位", + "fields": "欄位", + "formbricks_surveys": "Formbricks 問卷", + "hub_feedback_record_fields": "Hub 回饋紀錄欄位", + "iam_configuration_required": "需要 IAM 設定", + "iam_setup_instructions": "請將 Formbricks IAM 角色加入你的 S3 bucket policy 以啟用存取權限。", + "import_csv_data": "匯入 CSV 資料", + "load_sample_csv": "載入範例 CSV", + "n_elements": "{count} 個元素", + "no_source_fields_loaded": "尚未載入來源欄位", + "no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。", + "no_surveys_found": "此環境中找不到問卷", + "optional": "選填", + "or": "或", + "or_drag_and_drop": "或拖曳檔案", + "process_new_files_description": "自動處理丟到 bucket 裡的新檔案", + "processing_interval": "處理間隔", + "region_ap_southeast_1": "亞太區(新加坡)", + "region_eu_central_1": "歐盟(法蘭克福)", + "region_eu_west_1": "歐盟(愛爾蘭)", + "region_us_east_1": "美國東部(維吉尼亞北部)", + "region_us_west_2": "美國西部(奧勒岡)", + "required": "必填", + "s3_bucket_description": "將 CSV 檔案放到你的 S3 bucket,就能自動匯入回饋。檔案每 15 分鐘處理一次。", + "s3_bucket_integration": "S3 Bucket 整合", + "save_changes": "儲存變更", + "select_a_survey_to_see_elements": "請選擇問卷以查看其元素", + "select_a_value": "請選擇一個值...", + "select_all": "全選", + "select_elements": "選取元素", + "select_questions": "選取問題", + "select_source_type_description": "請選擇你想要連接的回饋來源類型。", + "select_source_type_prompt": "請選擇你想要連接的回饋來源類型:", + "select_survey": "選擇問卷", + "select_survey_and_questions": "選擇問卷與問題", + "select_survey_questions_description": "請選擇哪些問卷問題要建立 FeedbackRecords。", + "set_value": "設定值", + "setup_connection": "設定連線", + "showing_rows": "顯示 {count} 筆資料中的 3 筆", + "source_connect_csv_description": "從 CSV 檔案匯入回饋", + "source_connect_formbricks_description": "連接來自你 Formbricks 問卷的回饋", + "source_fields": "來源欄位", + "source_name": "來源名稱", + "source_type_cannot_be_changed": "來源類型無法變更", + "sources": "來源", + "status_active": "啟用中", + "status_completed": "已完成", + "status_draft": "草稿", + "status_error": "錯誤", + "status_paused": "已暫停", + "survey_has_no_elements": "此問卷沒有任何問題", + "test_connection": "測試連線", + "unify_feedback": "整合回饋", + "update_mapping_description": "更新此來源的對應設定。", + "upload_csv_data_description": "上傳 CSV 檔案或設定自動 S3 匯入。", + "upload_csv_file": "上傳 CSV 檔案", + "view_setup_guide": "查看設定指南 →", + "yes_delete": "確定刪除" + }, "workspace": { "api_keys": { "add_api_key": "新增 API 金鑰", diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx index ed843be4ac..51850440d3 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx @@ -42,7 +42,7 @@ export const SingleResponseCardBody = ({ return ( + className="ml-0.5 mr-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0"> @{part} ); diff --git a/apps/web/modules/ee/contacts/attributes/components/attribute-table-column.tsx b/apps/web/modules/ee/contacts/attributes/components/attribute-table-column.tsx index 2b50268e1e..24d76aea3f 100644 --- a/apps/web/modules/ee/contacts/attributes/components/attribute-table-column.tsx +++ b/apps/web/modules/ee/contacts/attributes/components/attribute-table-column.tsx @@ -46,7 +46,7 @@ export const generateAttributeTableColumns = ( cell: ({ row }) => { const description = row.original.description; return description ? ( -
+
) : ( diff --git a/apps/web/modules/ee/contacts/components/upload-contacts-attribute.tsx b/apps/web/modules/ee/contacts/components/upload-contacts-attribute.tsx index 19bae9c7a6..79a3052da2 100644 --- a/apps/web/modules/ee/contacts/components/upload-contacts-attribute.tsx +++ b/apps/web/modules/ee/contacts/components/upload-contacts-attribute.tsx @@ -132,7 +132,7 @@ export const UploadContactsAttributes = ({ return ( <> - {csvColumn} + {csvColumn}
-
+
) : ( -

+

{t("environments.workspace.languages.no_language_found")}

)} diff --git a/apps/web/modules/projects/settings/(setup)/components/ActionActivityTab.tsx b/apps/web/modules/projects/settings/(setup)/components/ActionActivityTab.tsx index 34eb81c026..4608e1e829 100644 --- a/apps/web/modules/projects/settings/(setup)/components/ActionActivityTab.tsx +++ b/apps/web/modules/projects/settings/(setup)/components/ActionActivityTab.tsx @@ -151,7 +151,7 @@ export const ActionActivityTab = ({
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
-

{actionClass.type}

+

{actionClass.type}

diff --git a/apps/web/modules/projects/settings/components/project-config-navigation.tsx b/apps/web/modules/projects/settings/components/project-config-navigation.tsx index 116db7fb68..d0d3acde8f 100644 --- a/apps/web/modules/projects/settings/components/project-config-navigation.tsx +++ b/apps/web/modules/projects/settings/components/project-config-navigation.tsx @@ -1,6 +1,14 @@ "use client"; -import { BlocksIcon, BrushIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react"; +import { + BlocksIcon, + BrushIcon, + Cable, + LanguagesIcon, + ListChecksIcon, + TagIcon, + UsersIcon, +} from "lucide-react"; import { usePathname } from "next/navigation"; import { useTranslation } from "react-i18next"; import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; @@ -69,6 +77,13 @@ export const ProjectConfigNavigation = ({ href: `/environments/${environmentId}/workspace/tags`, current: pathname?.includes("/tags"), }, + { + id: "unify", + label: t("environments.unify.unify_feedback"), + icon: , + href: `/environments/${environmentId}/workspace/unify`, + current: pathname?.includes("/unify"), + }, ]; return ; diff --git a/apps/web/modules/survey/editor/components/end-screen-form.tsx b/apps/web/modules/survey/editor/components/end-screen-form.tsx index 86b3532035..d9cb2daf5c 100644 --- a/apps/web/modules/survey/editor/components/end-screen-form.tsx +++ b/apps/web/modules/survey/editor/components/end-screen-form.tsx @@ -185,7 +185,7 @@ export const EndScreenForm = ({
{/* The highlight container is absolutely positioned behind the input */}
{highlightedJSX} diff --git a/apps/web/modules/survey/editor/components/hidden-fields-card.tsx b/apps/web/modules/survey/editor/components/hidden-fields-card.tsx index 1fc3439f84..c5d30c42df 100644 --- a/apps/web/modules/survey/editor/components/hidden-fields-card.tsx +++ b/apps/web/modules/survey/editor/components/hidden-fields-card.tsx @@ -158,7 +158,7 @@ export const HiddenFieldsCard = ({
@@ -191,7 +191,7 @@ export const HiddenFieldsCard = ({ ); }) ) : ( -

+

{t("environments.surveys.edit.no_hidden_fields_yet_add_first_one_below")}

)} diff --git a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx index 9333f351c4..df8dbf6a26 100644 --- a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx +++ b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx @@ -475,7 +475,7 @@ export const SurveyMenuBar = ({ />
-
+
{!isStorageConfigured && (
diff --git a/apps/web/modules/ui/components/data-table/components/data-table-header.tsx b/apps/web/modules/ui/components/data-table/components/data-table-header.tsx index 94463c7be2..7a8af483c4 100644 --- a/apps/web/modules/ui/components/data-table/components/data-table-header.tsx +++ b/apps/web/modules/ui/components/data-table/components/data-table-header.tsx @@ -68,7 +68,7 @@ export const DataTableHeader = ({ onTouchStart={header.getResizeHandler()} data-testid="column-resize-handle" className={cn( - "absolute top-0 right-0 hidden h-full w-1 cursor-col-resize bg-slate-500", + "absolute right-0 top-0 hidden h-full w-1 cursor-col-resize bg-slate-500", header.column.getIsResizing() ? "bg-black" : "bg-slate-500", !header.column.getCanResize() ? "hidden" : "group-hover:block" )}> diff --git a/apps/web/modules/ui/components/input-combo-box/index.tsx b/apps/web/modules/ui/components/input-combo-box/index.tsx index 957c0246de..84733d5f59 100644 --- a/apps/web/modules/ui/components/input-combo-box/index.tsx +++ b/apps/web/modules/ui/components/input-combo-box/index.tsx @@ -226,7 +226,7 @@ export const InputCombobox: React.FC = ({ tabIndex={0} aria-controls="options" aria-expanded={open} - className={cn("flex w-full cursor-pointer items-center justify-end bg-white pr-2 h-10", { + className={cn("flex h-10 w-full cursor-pointer items-center justify-end bg-white pr-2", { "w-10 justify-center pr-0": withInput && inputType !== "dropdown", "pointer-events-none": isClearing, })}> diff --git a/apps/web/modules/ui/components/select/index.tsx b/apps/web/modules/ui/components/select/index.tsx index 8cc3e44c33..c7ddbac0b6 100644 --- a/apps/web/modules/ui/components/select/index.tsx +++ b/apps/web/modules/ui/components/select/index.tsx @@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef< @@ -62,7 +62,7 @@ const SelectLabel: React.ComponentType = React >(({ className, ...props }, ref) => ( )); @@ -75,7 +75,7 @@ const SelectItem: React.ComponentType = React.f diff --git a/apps/web/modules/ui/components/styling-tabs/index.tsx b/apps/web/modules/ui/components/styling-tabs/index.tsx index a7e3983910..6b4e99f2a6 100644 --- a/apps/web/modules/ui/components/styling-tabs/index.tsx +++ b/apps/web/modules/ui/components/styling-tabs/index.tsx @@ -61,7 +61,7 @@ export const StylingTabs = ({ className={cn( "flex flex-1 cursor-pointer items-center justify-center gap-4 rounded-md py-2 text-center text-sm", selectedOption === option.value ? "bg-slate-100" : "bg-white", - "focus:ring-brand-dark focus:ring-opacity-50 focus:ring-2 focus:outline-none", + "focus:ring-brand-dark focus:outline-none focus:ring-2 focus:ring-opacity-50", selectedOption === option.value ? activeTabClassName : inactiveTabClassName )}> { await user.login(); // Navigate to Look & Feel settings - await page.getByRole("link", { name: "Configuration" }).click(); + await page.getByRole("link", { name: "Configure" }).click(); await page.getByRole("link", { name: "Look & Feel" }).click(); await page.waitForURL(/\/environments\/[^/]+\/workspace\/look/); @@ -171,7 +171,7 @@ test.describe("Survey Styling", async () => { await user.login(); // Navigate to Look & Feel settings - await page.getByRole("link", { name: "Configuration" }).click(); + await page.getByRole("link", { name: "Configure" }).click(); await page.getByRole("link", { name: "Look & Feel" }).click(); await page.waitForURL(/\/environments\/[^/]+\/workspace\/look/); @@ -260,7 +260,7 @@ test.describe("Survey Styling", async () => { await user.login(); // Navigate to Look & Feel settings - await page.getByRole("link", { name: "Configuration" }).click(); + await page.getByRole("link", { name: "Configure" }).click(); await page.getByRole("link", { name: "Look & Feel" }).click(); await page.waitForURL(/\/environments\/[^/]+\/workspace\/look/); diff --git a/apps/web/playwright/survey.spec.ts b/apps/web/playwright/survey.spec.ts index 68e553c307..fd4edd012f 100644 --- a/apps/web/playwright/survey.spec.ts +++ b/apps/web/playwright/survey.spec.ts @@ -248,7 +248,7 @@ test.describe("Multi Language Survey Create", async () => { await page.waitForURL(/\/environments\/[^/]+\/surveys/); //add a new language - await page.getByRole("link", { name: "Configuration" }).click(); + await page.getByRole("link", { name: "Configure" }).click(); await page.getByRole("link", { name: "Survey Languages" }).click(); await page.getByRole("button", { name: "Edit languages" }).click(); await page.getByRole("button", { name: "Add language" }).click(); @@ -265,7 +265,7 @@ test.describe("Multi Language Survey Create", async () => { await page.getByText("German", { exact: true }).nth(1).click(); await page.getByRole("button", { name: "Save changes" }).click(); await page.waitForTimeout(2000); - await page.getByRole("link", { name: "Surveys" }).click(); + await page.getByRole("link", { name: "Ask" }).click(); await page.getByText("Start from scratch").click(); await page.getByRole("button", { name: "Create survey", exact: true }).click(); await page.locator("#multi-lang-toggle").click(); diff --git a/packages/database/migration/20260224045945_added_connector_model/migration.sql b/packages/database/migration/20260224045945_added_connector_model/migration.sql new file mode 100644 index 0000000000..2d67f10235 --- /dev/null +++ b/packages/database/migration/20260224045945_added_connector_model/migration.sql @@ -0,0 +1,76 @@ +-- CreateEnum +CREATE TYPE "public"."ConnectorType" AS ENUM ('formbricks', 'csv'); + +-- CreateEnum +CREATE TYPE "public"."ConnectorStatus" AS ENUM ('active', 'paused', 'error'); + +-- CreateEnum +CREATE TYPE "public"."HubFieldType" AS ENUM ('text', 'categorical', 'nps', 'csat', 'ces', 'rating', 'number', 'boolean', 'date'); + +-- CreateTable +CREATE TABLE "public"."Connector" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "name" TEXT NOT NULL, + "type" "public"."ConnectorType" NOT NULL, + "status" "public"."ConnectorStatus" NOT NULL DEFAULT 'active', + "environmentId" TEXT NOT NULL, + "last_sync_at" TIMESTAMP(3), + "error_message" TEXT, + + CONSTRAINT "Connector_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."ConnectorFormbricksMapping" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "connectorId" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + "surveyId" TEXT NOT NULL, + "elementId" TEXT NOT NULL, + "hubFieldType" "public"."HubFieldType" NOT NULL, + "custom_field_label" TEXT, + + CONSTRAINT "ConnectorFormbricksMapping_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."ConnectorFieldMapping" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "connectorId" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + "source_field_id" TEXT NOT NULL, + "target_field_id" TEXT NOT NULL, + "static_value" TEXT, + + CONSTRAINT "ConnectorFieldMapping_pkey" PRIMARY KEY ("id") +); + +-- Connector indexes +CREATE UNIQUE INDEX "Connector_id_environmentId_key" ON "public"."Connector"("id", "environmentId"); +CREATE UNIQUE INDEX "Connector_environmentId_name_key" ON "public"."Connector"("environmentId", "name"); +CREATE INDEX "Connector_type_idx" ON "public"."Connector"("type"); + +-- ConnectorFormbricksMapping indexes +CREATE UNIQUE INDEX "ConnectorFormbricksMapping_environmentId_connectorId_survey_key" ON "public"."ConnectorFormbricksMapping"("environmentId", "connectorId", "surveyId", "elementId"); +CREATE INDEX "ConnectorFormbricksMapping_environmentId_surveyId_idx" ON "public"."ConnectorFormbricksMapping"("environmentId", "surveyId"); +CREATE INDEX "ConnectorFormbricksMapping_surveyId_idx" ON "public"."ConnectorFormbricksMapping"("surveyId"); + +-- ConnectorFieldMapping indexes +CREATE UNIQUE INDEX "ConnectorFieldMapping_environmentId_connectorId_source_fiel_key" ON "public"."ConnectorFieldMapping"("environmentId", "connectorId", "source_field_id", "target_field_id"); + +-- Survey composite unique (for composite FK from ConnectorFormbricksMapping) +CREATE UNIQUE INDEX "Survey_id_environmentId_key" ON "public"."Survey"("id", "environmentId"); + +-- Foreign keys: Connector -> Environment +ALTER TABLE "public"."Connector" ADD CONSTRAINT "Connector_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "public"."Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Foreign keys: ConnectorFormbricksMapping -> Connector (composite), Survey (composite) +ALTER TABLE "public"."ConnectorFormbricksMapping" ADD CONSTRAINT "ConnectorFormbricksMapping_connectorId_environmentId_fkey" FOREIGN KEY ("connectorId", "environmentId") REFERENCES "public"."Connector"("id", "environmentId") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "public"."ConnectorFormbricksMapping" ADD CONSTRAINT "ConnectorFormbricksMapping_surveyId_environmentId_fkey" FOREIGN KEY ("surveyId", "environmentId") REFERENCES "public"."Survey"("id", "environmentId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Foreign keys: ConnectorFieldMapping -> Connector (composite) +ALTER TABLE "public"."ConnectorFieldMapping" ADD CONSTRAINT "ConnectorFieldMapping_connectorId_environmentId_fkey" FOREIGN KEY ("connectorId", "environmentId") REFERENCES "public"."Connector"("id", "environmentId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 17fa17e363..4ae1568794 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -392,25 +392,27 @@ model Survey { /// [SurveySingleUse] singleUse Json? @default("{\"enabled\": false, \"isEncrypted\": true}") - isVerifyEmailEnabled Boolean @default(false) - isSingleResponsePerEmailEnabled Boolean @default(false) - isBackButtonHidden Boolean @default(false) - isCaptureIpEnabled Boolean @default(false) + isVerifyEmailEnabled Boolean @default(false) + isSingleResponsePerEmailEnabled Boolean @default(false) + isBackButtonHidden Boolean @default(false) + isCaptureIpEnabled Boolean @default(false) pin String? displayPercentage Decimal? languages SurveyLanguage[] showLanguageSwitch Boolean? followUps SurveyFollowUp[] /// [SurveyRecaptcha] - recaptcha Json? @default("{\"enabled\": false, \"threshold\":0.1}") + recaptcha Json? @default("{\"enabled\": false, \"threshold\":0.1}") /// [SurveyLinkMetadata] - metadata Json @default("{}") + metadata Json @default("{}") + connectorMappings ConnectorFormbricksMapping[] slug String? @unique customHeadScripts String? customHeadScriptsMode SurveyScriptMode? @default(add) + @@unique([id, environmentId]) @@index([environmentId, updatedAt]) @@index([segmentId]) } @@ -595,6 +597,7 @@ model Environment { segments Segment[] integration Integration[] ApiKeyEnvironment ApiKeyEnvironment[] + connectors Connector[] @@index([projectId]) } @@ -1004,3 +1007,103 @@ model ProjectTeam { @@id([projectId, teamId]) @@index([teamId]) } + +enum ConnectorType { + formbricks + csv +} + +enum ConnectorStatus { + active + paused + error +} + +enum HubFieldType { + text + categorical + nps + csat + ces + rating + number + boolean + date +} + +/// Base connector for all integration types. +/// Connects external data sources to the Hub for feedback record creation. +/// +/// @property id - Unique identifier for the connector +/// @property name - Display name for the connector +/// @property type - Type of connector (formbricks, webhook, csv, email, slack) +/// @property status - Current state of the connector (active, paused, error) +/// @property environment - The environment this connector belongs to +/// @property config - Type-specific configuration (e.g., webhook secret, S3 config) +/// @property formbricksMappings - Element mappings for Formbricks connectors +/// @property fieldMappings - Field mappings for other connector types +model Connector { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + type ConnectorType + status ConnectorStatus @default(active) + environmentId String + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + formbricksMappings ConnectorFormbricksMapping[] + fieldMappings ConnectorFieldMapping[] + lastSyncAt DateTime? @map(name: "last_sync_at") + errorMessage String? @map(name: "error_message") + + @@unique([id, environmentId]) + @@unique([environmentId, name]) + @@index([type]) +} + +/// Maps survey elements to Hub FeedbackRecords for Formbricks connectors. +/// Each row represents one element that will create FeedbackRecords when answered. +/// +/// @property id - Unique identifier for the mapping +/// @property connector - The parent connector +/// @property survey - The survey containing the element +/// @property elementId - The element ID within the survey (from blocks[].elements[].id) +/// @property hubFieldType - The field_type to use in Hub (text, nps, rating, etc.) +/// @property customFieldLabel - Optional override for the element headline as field_label in Hub +model ConnectorFormbricksMapping { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + connectorId String + environmentId String + connector Connector @relation(fields: [connectorId, environmentId], references: [id, environmentId], onDelete: Cascade) + surveyId String + survey Survey @relation(fields: [surveyId, environmentId], references: [id, environmentId], onDelete: Cascade) + elementId String + hubFieldType HubFieldType + customFieldLabel String? @map(name: "custom_field_label") + + @@unique([environmentId, connectorId, surveyId, elementId]) + @@index([environmentId, surveyId]) + @@index([surveyId]) +} + +/// Generic field mapping for Webhook, CSV, Email, Slack connectors. +/// Maps source fields to Hub FeedbackRecord fields. +/// +/// @property id - Unique identifier for the mapping +/// @property connector - The parent connector +/// @property sourceFieldId - Field path for webhook (e.g., "user.id"), column name for CSV +/// @property targetFieldId - Hub field (collected_at, field_id, value_text, etc.) +/// @property staticValue - If set, use this value instead of reading from sourceFieldId +model ConnectorFieldMapping { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + connectorId String + environmentId String + connector Connector @relation(fields: [connectorId, environmentId], references: [id, environmentId], onDelete: Cascade) + sourceFieldId String @map(name: "source_field_id") + targetFieldId String @map(name: "target_field_id") + staticValue String? @map(name: "static_value") + + @@unique([environmentId, connectorId, sourceFieldId, targetFieldId]) +} diff --git a/packages/i18n-utils/src/scan-translations.ts b/packages/i18n-utils/src/scan-translations.ts index 8f0271f42b..243f0176e1 100644 --- a/packages/i18n-utils/src/scan-translations.ts +++ b/packages/i18n-utils/src/scan-translations.ts @@ -46,6 +46,10 @@ const TRANSLATION_PATTERNS = [ /i18nKey\s*=\s*\{\s*["'](?[^"']+)["']\s*\}/g, ]; +// Extracts string literals from dynamic i18nKey={...} expressions (e.g. ternaries) +const I18N_KEY_BLOCK_PATTERN = /i18nKey\s*=\s*\{(?[\s\S]*?)\}/g; +const STRING_LITERAL_PATTERN = /["'](?[^"']+)["']/g; + // Directories and files to exclude from scanning const EXCLUDE_DIRS = [ "**/node_modules/**", @@ -134,6 +138,21 @@ export function extractKeysFromContent(content: string): string[] { } } + // Extract keys from dynamic i18nKey={...} expressions (e.g. ternaries, conditionals) + I18N_KEY_BLOCK_PATTERN.lastIndex = 0; + let blockMatch: RegExpExecArray | null = null; + while ((blockMatch = I18N_KEY_BLOCK_PATTERN.exec(contentWithoutComments)) !== null) { + const blockContent = blockMatch.groups?.block ?? ""; + STRING_LITERAL_PATTERN.lastIndex = 0; + let strMatch: RegExpExecArray | null = null; + while ((strMatch = STRING_LITERAL_PATTERN.exec(blockContent)) !== null) { + const key = strMatch.groups?.key ?? ""; + if (key.includes(".") && !key.includes("${") && !key.includes(" ")) { + keys.push(key); + } + } + } + return keys; } diff --git a/packages/surveys/src/components/wrappers/survey-container.tsx b/packages/surveys/src/components/wrappers/survey-container.tsx index 137fdd2ee4..6c164ea3af 100644 --- a/packages/surveys/src/components/wrappers/survey-container.tsx +++ b/packages/surveys/src/components/wrappers/survey-container.tsx @@ -82,7 +82,7 @@ export function SurveyContainer({ aria-live="assertive" className={cn( hasOverlay ? "pointer-events-auto" : "pointer-events-none", - isModal && "fixed inset-0 z-999999 flex items-end" + isModal && "z-999999 fixed inset-0 flex items-end" )}>
; + +// Connector status enum +export const ZConnectorStatus = z.enum(["active", "paused", "error"]); +export type TConnectorStatus = z.infer; + +// Hub field types (from Hub OpenAPI spec) +export const ZHubFieldType = z.enum([ + "text", + "categorical", + "nps", + "csat", + "ces", + "rating", + "number", + "boolean", + "date", +]); +export type THubFieldType = z.infer; + +// Hub target fields for mapping +export const ZHubTargetField = z.enum([ + "collected_at", + "source_type", + "field_id", + "field_type", + "field_label", + "field_group_id", + "field_group_label", + "tenant_id", + "source_id", + "source_name", + "value_text", + "value_number", + "value_boolean", + "value_date", + "metadata", + "language", + "user_identifier", +]); +export type THubTargetField = z.infer; + +// Base connector schema +export const ZConnector = z.object({ + id: z.string().cuid2(), + createdAt: z.date(), + updatedAt: z.date(), + name: z.string().min(1), + type: ZConnectorType, + status: ZConnectorStatus, + environmentId: z.string().cuid2(), + lastSyncAt: z.date().nullable(), + errorMessage: z.string().nullable(), +}); +export type TConnector = z.infer; + +// Formbricks element mapping +export const ZConnectorFormbricksMapping = z.object({ + id: z.string().cuid2(), + createdAt: z.date(), + connectorId: z.string().cuid2(), + environmentId: z.string().cuid2(), + surveyId: z.string().cuid2(), + elementId: z.string(), + hubFieldType: ZHubFieldType, + customFieldLabel: z.string().nullable(), +}); +export type TConnectorFormbricksMapping = z.infer; + +export const ZConnectorFieldMapping = z.object({ + id: z.string().cuid2(), + createdAt: z.date(), + connectorId: z.string().cuid2(), + environmentId: z.string().cuid2(), + sourceFieldId: z.string(), + targetFieldId: ZHubTargetField, + staticValue: z.string().nullable(), +}); +export type TConnectorFieldMapping = z.infer; + +export const ZConnectorWithMappings = ZConnector.extend({ + formbricksMappings: z.array(ZConnectorFormbricksMapping), + fieldMappings: z.array(ZConnectorFieldMapping), +}); +export type TConnectorWithMappings = z.infer; + +// Create input schemas +export const ZConnectorCreateInput = z.object({ + name: z.string().min(1), + type: ZConnectorType, +}); +export type TConnectorCreateInput = z.infer; + +// Create Formbricks mapping input +export const ZConnectorFormbricksMappingCreateInput = z.object({ + surveyId: z.string().cuid2(), + elementId: z.string(), + hubFieldType: ZHubFieldType, + customFieldLabel: z.string().optional(), +}); +export type TConnectorFormbricksMappingCreateInput = z.infer; + +// Create field mapping input +export const ZConnectorFieldMappingCreateInput = z.object({ + sourceFieldId: z.string(), + targetFieldId: ZHubTargetField, + staticValue: z.string().optional(), +}); +export type TConnectorFieldMappingCreateInput = z.infer; + +// Update connector input +export const ZConnectorUpdateInput = z.object({ + name: z.string().min(1).optional(), + status: ZConnectorStatus.optional(), + errorMessage: z.string().nullable().optional(), + lastSyncAt: z.date().nullable().optional(), +}); +export type TConnectorUpdateInput = z.infer; + +// Element type to Hub field type mapping helper +export const ELEMENT_TYPE_TO_HUB_FIELD_TYPE: Record = { + openText: "text", + nps: "nps", + rating: "rating", + multipleChoiceSingle: "categorical", + multipleChoiceMulti: "categorical", + date: "date", + consent: "boolean", + matrix: "categorical", + ranking: "categorical", + pictureSelection: "categorical", + contactInfo: "text", + address: "text", + fileUpload: "text", + cal: "text", + cta: "boolean", +}; + +// Helper function to get Hub field type from element type +export const getHubFieldTypeFromElementType = (elementType: string): THubFieldType => { + return ELEMENT_TYPE_TO_HUB_FIELD_TYPE[elementType]; +}; diff --git a/turbo.json b/turbo.json index 7c160c19b2..ea1c8152a9 100644 --- a/turbo.json +++ b/turbo.json @@ -254,7 +254,9 @@ "UNSPLASH_ACCESS_KEY", "PROMETHEUS_ENABLED", "PROMETHEUS_EXPORTER_PORT", - "USER_MANAGEMENT_MINIMUM_ROLE" + "USER_MANAGEMENT_MINIMUM_ROLE", + "HUB_API_URL", + "HUB_API_KEY" ], "outputs": ["dist/**", ".next/**"] },