Compare commits

..

1 Commits

Author SHA1 Message Date
TheodorTomas
5ad3c009c2 WIP squash this commit 2026-02-09 18:28:40 +07:00
414 changed files with 6221 additions and 26845 deletions

View File

@@ -38,15 +38,6 @@ LOG_LEVEL=info
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
################
# MAIL SETUP #
################
@@ -193,13 +184,8 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# OTEL_SERVICE_NAME=formbricks
# OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development
# OTEL_TRACES_SAMPLER=parentbased_traceidratio
# OTEL_TRACES_SAMPLER_ARG=1
# OpenTelemetry URL for tracing
# OPENTELEMETRY_LISTENER_URL=http://localhost:4318/v1/traces
# Unsplash API Key
UNSPLASH_ACCESS_KEY=

View File

@@ -65,8 +65,8 @@ jobs:
set -euo pipefail
echo "Updating Chart.yaml with version: ${VERSION}"
yq -i ".version = \"${VERSION}\"" charts/formbricks/Chart.yaml
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
echo "✅ Successfully updated Chart.yaml"
@@ -77,7 +77,7 @@ jobs:
set -euo pipefail
echo "Packaging Helm chart version: ${VERSION}"
helm package ./charts/formbricks
helm package ./helm-chart
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"

View File

@@ -9,7 +9,6 @@ on:
merge_group:
permissions:
contents: read
pull-requests: read
jobs:
sonarqube:
name: SonarQube
@@ -51,9 +50,6 @@ jobs:
pnpm test:coverage
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
with:
args: >
-Dsonar.verbose=true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

3
.gitignore vendored
View File

@@ -13,7 +13,6 @@
**/.next/
**/out/
**/build
**/next-env.d.ts
# node
**/dist/
@@ -64,5 +63,3 @@ packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdat
.cursorrules
i18n.cache
stats.html
# next-agents-md
.next-docs/

File diff suppressed because one or more lines are too long

View File

@@ -126,22 +126,6 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
# Pino loads transport code in worker threads via dynamic require().
# Next.js file tracing only traces static imports, missing runtime-loaded files
# (e.g. pino/lib/transport-stream.js, transport targets).
# Copy the full packages to ensure all runtime files are available.
COPY --from=installer /app/node_modules/pino ./node_modules/pino
RUN chmod -R 755 ./node_modules/pino
COPY --from=installer /app/node_modules/pino-opentelemetry-transport ./node_modules/pino-opentelemetry-transport
RUN chmod -R 755 ./node_modules/pino-opentelemetry-transport
COPY --from=installer /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
RUN chmod -R 755 ./node_modules/pino-abstract-transport
COPY --from=installer /app/node_modules/otlp-logger ./node_modules/otlp-logger
RUN chmod -R 755 ./node_modules/otlp-logger
# Install prisma CLI globally for database migrations and fix permissions for nextjs user
RUN npm install --ignore-scripts -g prisma@6 \
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma

View File

@@ -25,7 +25,7 @@ const mockProject: TProject = {
},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
languages: [],
logo: null,

View File

@@ -3,7 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -17,7 +17,6 @@ import {
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
@@ -65,17 +64,10 @@ export const ProjectSettings = ({
const { t } = useTranslation();
const addProject = async (data: TProjectUpdateInput) => {
try {
// Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted.
// Without this, only brandColor is saved and the look-and-feel page falls
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createProjectResponse = await createProjectAction({
organizationId,
data: {
...data,
styling: fullStyling,
config: { channel, industry },
teamIds: data.teamIds,
},
@@ -120,7 +112,6 @@ export const ProjectSettings = ({
const projectName = form.watch("name");
const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
const { isSubmitting } = form.formState;
const organizationTeamsOptions = organizationTeams.map((team) => ({
@@ -235,7 +226,7 @@ export const ProjectSettings = ({
alt="Logo"
width={256}
height={56}
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
<p className="text-sm text-slate-400">{t("common.preview")}</p>
@@ -244,7 +235,7 @@ export const ProjectSettings = ({
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={previewStyling}
styling={{ brandColor: { light: brandColor } }}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}

View File

@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
@@ -138,7 +138,7 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
// Need membership for getProjectsByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) {
throw new AuthorizationError("Membership not found");
throw new Error("Membership not found");
}
return await getProjectsByUserId(ctx.user.id, membership);

View File

@@ -9,7 +9,6 @@ import {
PanelLeftCloseIcon,
PanelLeftOpenIcon,
RocketIcon,
ShapesIcon,
UserCircleIcon,
UserIcon,
} from "lucide-react";
@@ -100,7 +99,7 @@ export const MainNavigation = ({
const mainNavigation = useMemo(
() => [
{
name: t("common.ask"),
name: t("common.surveys"),
href: `/environments/${environment.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
@@ -108,21 +107,12 @@ export const MainNavigation = ({
},
{
href: `/environments/${environment.id}/contacts`,
name: t("common.distribute"),
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
},
{
name: t("common.unify"),
href: `/environments/${environment.id}/workspace/unify`,
icon: ShapesIcon,
isActive: pathname?.includes("/unify") && !pathname?.includes("/analyze"),
},
{
name: t("common.configure"),
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/project"),
@@ -195,7 +185,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:outline-none focus:ring-0 focus:ring-transparent"
"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"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />

View File

@@ -133,11 +133,6 @@ 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) {

View File

@@ -11,12 +11,6 @@ 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)

View File

@@ -1,142 +0,0 @@
"use client";
import { TFunction } from "i18next";
import { RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { SettingsCard } from "../../../components/SettingsCard";
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
interface EnterpriseLicenseStatusProps {
status: LicenseStatus;
gracePeriodEnd?: Date;
environmentId: string;
}
const getBadgeConfig = (
status: LicenseStatus,
t: TFunction
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
switch (status) {
case "active":
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
case "expired":
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
case "unreachable":
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
case "invalid_license":
return { type: "error", label: t("environments.settings.enterprise.license_status_invalid") };
default:
return { type: "gray", label: t("environments.settings.enterprise.license_status") };
}
};
export const EnterpriseLicenseStatus = ({
status,
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
const { t } = useTranslation();
const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false);
const handleRecheck = async () => {
setIsRechecking(true);
try {
const result = await recheckLicenseAction({ environmentId });
if (result?.serverError) {
toast.error(result.serverError || t("environments.settings.enterprise.recheck_license_failed"));
return;
}
if (result?.data) {
if (result.data.status === "unreachable") {
toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
} else if (result.data.status === "invalid_license") {
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
} else {
toast.success(t("environments.settings.enterprise.recheck_license_success"));
}
router.refresh();
} else {
toast.error(t("environments.settings.enterprise.recheck_license_failed"));
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("environments.settings.enterprise.recheck_license_failed")
);
} finally {
setIsRechecking(false);
}
};
const badgeConfig = getBadgeConfig(status, t);
return (
<SettingsCard
title={t("environments.settings.enterprise.license_status")}
description={t("environments.settings.enterprise.license_status_description")}>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRecheck}
disabled={isRechecking}
className="shrink-0">
{isRechecking ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4 animate-spin" />
{t("environments.settings.enterprise.rechecking")}
</>
) : (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
{t("environments.settings.enterprise.recheck_license")}
</>
)}
</Button>
</div>
{status === "unreachable" && gracePeriodEnd && (
<Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})}
</AlertDescription>
</Alert>
)}
{status === "invalid_license" && (
<Alert variant="error" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_invalid_description")}
</AlertDescription>
</Alert>
)}
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a
className="font-medium text-slate-700 underline hover:text-slate-900"
href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
</p>
</div>
</SettingsCard>
);
};

View File

@@ -2,10 +2,9 @@ import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
@@ -26,8 +25,7 @@ const Page = async (props) => {
return notFound();
}
const licenseState = await getEnterpriseLicense();
const hasLicense = licenseState.status !== "no-license";
const { active: isEnterpriseEdition } = await getEnterpriseLicense();
const paidFeatures = [
{
@@ -92,22 +90,35 @@ const Page = async (props) => {
activeId="enterprise"
/>
</PageHeader>
{hasLicense ? (
<EnterpriseLicenseStatus
status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
{isEnterpriseEdition ? (
<div>
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="space-y-4 p-8">
<div className="flex items-center gap-x-2">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
</div>
<p className="text-slate-800">
{t(
"environments.settings.enterprise.your_enterprise_license_is_active_all_features_unlocked"
)}
</p>
</div>
<p className="text-sm text-slate-500">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a className="font-semibold underline" href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
</p>
</div>
</div>
</div>
) : (
<div>
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg
viewBox="0 0 1024 1024"
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true">
<circle
cx={512}
@@ -142,8 +153,8 @@ const Page = async (props) => {
{t("environments.settings.enterprise.enterprise_features")}
</h2>
<ul className="my-4 space-y-4">
{paidFeatures.map((feature) => (
<li key={feature.title} className="flex items-center">
{paidFeatures.map((feature, index) => (
<li key={index} className="flex items-center">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
</div>

View File

@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -33,12 +32,7 @@ export const DeleteOrganization = ({
setIsDeleting(true);
try {
const result = await deleteOrganizationAction({ organizationId: organization.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsDeleting(false);
return;
}
await deleteOrganizationAction({ organizationId: organization.id });
toast.success(t("environments.settings.general.organization_deleted_successfully"));
if (typeof localStorage !== "undefined") {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);

View File

@@ -384,24 +384,24 @@ export const generateResponseTableColumns = (
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
return {
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">
<EyeOffIcon className="h-4 w-4" />
</span>
<span className="truncate">{hiddenFieldId}</span>
</div>
),
cell: ({ row }) => {
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
if (typeof hiddenFieldResponse === "string") {
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
}
},
};
})
return {
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">
<EyeOffIcon className="h-4 w-4" />
</span>
<span className="truncate">{hiddenFieldId}</span>
</div>
),
cell: ({ row }) => {
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
if (typeof hiddenFieldResponse === "string") {
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
}
},
};
})
: [];
const metadataColumns = getMetadataColumnsData(t);

View File

@@ -8,7 +8,7 @@ import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surv
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getOriginalFileNameFromUrl } from "@/modules/storage/url-helpers";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";

View File

@@ -21,7 +21,6 @@ import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[envir
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
@@ -269,14 +268,7 @@ export const AddIntegrationModal = ({
airtableIntegrationData.config?.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: airtableIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: airtableIntegrationData });
if (isEditMode) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -312,11 +304,7 @@ export const AddIntegrationModal = ({
const integrationData = structuredClone(airtableIntegrationData);
integrationData.config.data.splice(index, 1);
const result = await createOrUpdateIntegrationAction({ environmentId, integrationData });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData });
handleClose();
router.refresh();

View File

@@ -165,14 +165,7 @@ export const AddIntegrationModal = ({
// create action
googleSheetIntegrationData.config.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: googleSheetIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -212,14 +205,7 @@ export const AddIntegrationModal = ({
googleSheetIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: googleSheetIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
@@ -280,7 +266,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{surveyElements.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">

View File

@@ -22,7 +22,6 @@ import {
createEmptyMapping,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
import NotionLogo from "@/images/notion.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { Button } from "@/modules/ui/components/button";
@@ -218,14 +217,7 @@ export const AddIntegrationModal = ({
notionIntegrationData.config.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: notionIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -244,14 +236,7 @@ export const AddIntegrationModal = ({
notionIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: notionIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {

View File

@@ -17,7 +17,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import SlackLogo from "@/images/slacklogo.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
@@ -145,14 +144,7 @@ export const AddChannelMappingModal = ({
// create action
slackIntegrationData.config.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: slackIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -189,14 +181,7 @@ export const AddChannelMappingModal = ({
slackIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: slackIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {

View File

@@ -1,25 +0,0 @@
"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 <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
};

View File

@@ -1,6 +0,0 @@
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`);
}

View File

@@ -1,38 +0,0 @@
"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<TUnifySurvey[]> => {
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));
});

View File

@@ -1,183 +0,0 @@
"use client";
import {
CopyIcon,
FileSpreadsheetIcon,
MoreVertical,
PauseIcon,
PlayIcon,
SquarePenIcon,
TrashIcon,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { CsvImportSection } from "./csv-import-section";
interface ConnectorRowDropdownProps {
connector: TConnectorWithMappings;
onEdit: () => void;
onDuplicate: () => Promise<void>;
onToggleStatus: () => Promise<void>;
onDelete: () => Promise<void>;
}
export function ConnectorRowDropdown({
connector,
onEdit,
onDuplicate,
onToggleStatus,
onDelete,
}: ConnectorRowDropdownProps) {
const { t } = useTranslation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isCsvImportDialogOpen, setIsCsvImportDialogOpen] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isActive = connector.status === "active";
const handleDelete = async () => {
setIsDeleting(true);
try {
await onDelete();
} finally {
setIsDeleting(false);
setIsDeleteDialogOpen(false);
}
};
return (
<div // eslint-disable-next-line jsx-a11y/no-static-element-interactions
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
data-testid="connector-row-dropdown">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild>
<div className="cursor-pointer rounded-lg border bg-white p-2 hover:bg-slate-50">
<span className="sr-only">{t("environments.surveys.open_options")}</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max">
<DropdownMenuGroup>
{connector.type === "csv" && (
<>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setIsCsvImportDialogOpen(true);
}}>
<FileSpreadsheetIcon className="mr-2 h-4 w-4" />
{t("environments.unify.import_csv_data")}
</button>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
onEdit();
}}>
<SquarePenIcon className="mr-2 h-4 w-4" />
{t("common.edit")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
await onDuplicate();
}}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
await onToggleStatus();
}}>
{isActive ? <PauseIcon className="mr-2 h-4 w-4" /> : <PlayIcon className="mr-2 h-4 w-4" />}
{isActive ? t("common.disable") : t("common.enable")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setIsDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat={t("environments.unify.source")}
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
{connector.type === "csv" && (
<Dialog open={isCsvImportDialogOpen} onOpenChange={setIsCsvImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.unify.import_csv_data")}</DialogTitle>
<DialogDescription>{t("environments.unify.upload_csv_data_description")}</DialogDescription>
</DialogHeader>
<CsvImportSection
connectorId={connector.id}
environmentId={connector.environmentId}
onImportComplete={() => setIsCsvImportDialogOpen(false)}
/>
</DialogContent>
</Dialog>
)}
</div>
);
}

View File

@@ -1,56 +0,0 @@
"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 (
<div className="space-y-3">
<p className="text-sm text-slate-600">{t("environments.unify.select_source_type_prompt")}</p>
<div className="space-y-2">
{connectorOptions.map((option) => (
<button
key={option.id}
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id as TConnectorType)}
className={`flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors ${
selectedType === option.id
? "border-brand-dark bg-slate-50"
: option.disabled
? "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60"
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
</div>
<div
className={`ml-4 h-5 w-5 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-2 w-2 rounded-full bg-white" />
</div>
)}
</div>
</button>
))}
</div>
</div>
);
}

View File

@@ -1,185 +0,0 @@
"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,
duplicateConnectorAction,
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<TConnectorWithMappings | null>(null);
const handleCreateConnector = async (data: {
name: string;
type: TConnectorType;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}): Promise<string | undefined> => {
const result = await createConnectorWithMappingsAction({
environmentId,
connectorInput: {
name: data.name,
type: data.type,
},
formbricksMappings:
data.type === "formbricks" && data.surveyMappings?.length ? data.surveyMappings : 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 undefined;
}
toast.success(t("environments.unify.connector_created_successfully"));
router.refresh();
return result.data.id;
};
const handleUpdateConnector = async (data: {
connectorId: string;
environmentId: string;
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => {
const result = await updateConnectorWithMappingsAction({
connectorId: data.connectorId,
environmentId,
connectorInput: {
name: data.name,
},
formbricksMappings: data.surveyMappings?.length ? data.surveyMappings : 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): Promise<void> => {
const result = await deleteConnectorAction({ connectorId, environmentId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.unify.connector_deleted_successfully"));
router.refresh();
};
const handleDuplicateConnector = async (connector: TConnectorWithMappings): Promise<void> => {
const result = await duplicateConnectorAction({
connectorId: connector.id,
environmentId,
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.unify.connector_duplicated_successfully"));
router.refresh();
};
const handleToggleStatus = async (connector: TConnectorWithMappings): Promise<void> => {
const newStatus = connector.status === "active" ? "paused" : "active";
const result = await updateConnectorWithMappingsAction({
connectorId: connector.id,
environmentId,
connectorInput: { status: newStatus },
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.unify.connector_status_updated_successfully"));
router.refresh();
};
return (
<PageContentWrapper>
<PageHeader
pageTitle={t("environments.unify.unify_feedback")}
cta={
<CreateConnectorModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreateConnector={handleCreateConnector}
surveys={initialSurveys}
environmentId={environmentId}
/>
}>
<UnifyConfigNavigation environmentId={environmentId} />
</PageHeader>
<div className="space-y-6">
<ConnectorsTable
connectors={initialConnectors}
onConnectorClick={setEditingConnector}
onDuplicate={handleDuplicateConnector}
onToggleStatus={handleToggleStatus}
onDelete={handleDeleteConnector}
isLoading={false}
/>
</div>
<EditConnectorModal
connector={editingConnector}
open={editingConnector !== null}
onOpenChange={(open) => !open && setEditingConnector(null)}
onUpdateConnector={handleUpdateConnector}
surveys={initialSurveys}
/>
</PageContentWrapper>
);
}

View File

@@ -1,133 +0,0 @@
"use client";
import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TConnectorStatus, TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
import { Badge } from "@/modules/ui/components/badge";
import { ConnectorRowDropdown } from "./connector-row-dropdown";
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) {
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 {
connector: TConnectorWithMappings;
onEdit: () => void;
onDuplicate: () => Promise<void>;
onToggleStatus: () => Promise<void>;
onDelete: () => Promise<void>;
}
function getConnectorIcon(type: TConnectorType) {
switch (type) {
case "formbricks":
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
default:
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
}
}
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
active: "success",
paused: "warning",
error: "error",
};
export function ConnectorsTableDataRow({
connector,
onEdit,
onDuplicate,
onToggleStatus,
onDelete,
}: 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 (
<div
role="button"
tabIndex={0}
className="grid h-12 min-h-12 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
onClick={onEdit}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onEdit();
}
}}>
<div className="col-span-1 flex items-center gap-2 pl-4" title={getConnectorTypeLabel(connector.type)}>
{getConnectorIcon(connector.type)}
</div>
<div className="col-span-3 flex items-center">
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
</div>
<div className="col-span-1 hidden items-center justify-center sm:flex">
<Badge
text={getStatusLabel(connector.status)}
type={STATUS_BADGE_TYPE[connector.status]}
size="tiny"
/>
</div>
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{getRelativeTime(connector.createdAt, i18n.language)}
</div>
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{getRelativeTime(connector.updatedAt, i18n.language)}
</div>
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
<span className="truncate">{connector.creatorName ?? "—"}</span>
</div>
<div className="col-span-1 flex items-center justify-end pr-2">
<ConnectorRowDropdown
connector={connector}
onEdit={onEdit}
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
</div>
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { useTranslation } from "react-i18next";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { ConnectorsTableDataRow } from "@/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connectors-table-data-row";
interface ConnectorsTableRowsContainerProps {
connectors: TConnectorWithMappings[];
onConnectorClick: (connector: TConnectorWithMappings) => void;
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
onDelete: (connectorId: string) => Promise<void>;
}
export const ConnectorsTableRowsContainer = ({
connectors,
onConnectorClick,
onDuplicate,
onToggleStatus,
onDelete,
}: ConnectorsTableRowsContainerProps) => {
const { t } = useTranslation();
if (connectors.length === 0) {
return (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-slate-500">{t("environments.unify.no_sources_connected")}</p>
</div>
);
}
return (
<div className="divide-y divide-slate-100">
{connectors.map((connector) => (
<ConnectorsTableDataRow
key={connector.id}
connector={connector}
onEdit={() => onConnectorClick(connector)}
onDuplicate={() => onDuplicate(connector)}
onToggleStatus={() => onToggleStatus(connector)}
onDelete={() => onDelete(connector.id)}
/>
))}
</div>
);
};

View File

@@ -1,53 +0,0 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { ConnectorsTableRowsContainer } from "@/app/(app)/environments/[environmentId]/workspace/unify/sources/components/connectors-table-rows-container";
interface ConnectorsTableProps {
connectors: TConnectorWithMappings[];
onConnectorClick: (connector: TConnectorWithMappings) => void;
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
onDelete: (connectorId: string) => Promise<void>;
isLoading?: boolean;
}
export function ConnectorsTable({
connectors,
onConnectorClick,
onDuplicate,
onToggleStatus,
onDelete,
isLoading = false,
}: ConnectorsTableProps) {
const { t } = useTranslation();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">{t("common.type")}</div>
<div className="col-span-3">{t("common.name")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.created")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("environments.unify.updated_at")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("environments.unify.created_by")}</div>
<div className="col-span-1" />
</div>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2Icon className="h-6 w-6 animate-spin text-slate-500" />
</div>
) : (
<ConnectorsTableRowsContainer
connectors={connectors}
onConnectorClick={onConnectorClick}
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
)}
</div>
);
}

View File

@@ -1,570 +0,0 @@
"use client";
import { Loader2Icon, PlusIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TConnectorType, UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
import {
getResponseCountAction,
importCsvDataAction,
importHistoricalResponsesAction,
} from "@/lib/connector/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
FEEDBACK_RECORD_FIELDS,
TCreateConnectorStep,
TFieldMapping,
TSourceField,
TUnifySurvey,
} from "../types";
import { TEnumValidationError, parseCSVColumnsToFields, validateEnumMappings } 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;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => Promise<string | undefined>;
surveys: TUnifySurvey[];
environmentId: string;
}
const getDialogTitle = (
step: TCreateConnectorStep,
type: TConnectorType | null,
t: (key: string) => string
): string => {
if (step === "selectType") return t("environments.unify.add_feedback_source");
if (type === "formbricks") return t("environments.unify.select_survey_and_questions");
if (type === "csv") return t("environments.unify.import_csv_data");
return t("environments.unify.configure_mapping");
};
const getDialogDescription = (
step: TCreateConnectorStep,
type: TConnectorType | null,
t: (key: string) => string
): string => {
if (step === "selectType") return t("environments.unify.select_source_type_description");
if (type === "formbricks") return t("environments.unify.select_survey_questions_description");
if (type === "csv") return t("environments.unify.upload_csv_data_description");
return t("environments.unify.configure_mapping");
};
const getNextStepButtonLabel = (type: TConnectorType | null, t: (key: string) => string): string => {
if (type === "formbricks") return t("environments.unify.select_questions");
if (type === "csv") return t("environments.unify.configure_import");
return t("environments.unify.create_mapping");
};
const getCreateDisabled = (
type: TConnectorType | null,
isFormbricksValid: boolean,
isCsvValid: boolean,
allRequiredMapped: boolean
): boolean => {
if (type === "formbricks") return !isFormbricksValid;
if (type === "csv") return !isCsvValid || !allRequiredMapped;
return !allRequiredMapped;
};
interface AggregateImportSectionProps {
surveyEntries: {
surveyId: string;
surveyName: string;
responseCount: number;
elementCount: number;
importHistorical: boolean;
}[];
onImportHistoricalChange: (surveyId: string, checked: boolean) => void;
t: (key: string, options?: Record<string, unknown>) => string;
}
const AggregateImportSection = ({
surveyEntries,
onImportHistoricalChange,
t,
}: AggregateImportSectionProps) => {
const totalRecords = surveyEntries.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
const checkedCount = surveyEntries.filter((e) => e.importHistorical).length;
const checkedTotal = surveyEntries
.filter((e) => e.importHistorical)
.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="space-y-2">
{surveyEntries.map((entry) => (
<label key={entry.surveyId} className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={entry.importHistorical}
onChange={(e) => onImportHistoricalChange(entry.surveyId, e.target.checked)}
className="h-4 w-4 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
/>
<span className="text-xs text-amber-800">
{t("environments.unify.survey_import_line", {
surveyName: entry.surveyName,
responseCount: entry.responseCount,
questionCount: entry.elementCount,
total: entry.responseCount * entry.elementCount,
})}
</span>
</label>
))}
</div>
{surveyEntries.length > 1 && (
<p className="mt-3 border-t border-amber-200 pt-2 text-xs font-medium text-amber-900">
{t("environments.unify.total_feedback_records", {
checked: checkedTotal,
total: totalRecords,
surveyCount: checkedCount,
})}
</p>
)}
</div>
);
};
export const CreateConnectorModal = ({
open,
onOpenChange,
onCreateConnector,
surveys,
environmentId,
}: CreateConnectorModalProps) => {
const { t } = useTranslation();
const defaultConnectorName: Record<TConnectorType, string> = {
formbricks: t("environments.unify.default_connector_name_formbricks"),
csv: t("environments.unify.default_connector_name_csv"),
};
const [currentStep, setCurrentStep] = useState<TCreateConnectorStep>("selectType");
const [selectedType, setSelectedType] = useState<TConnectorType | null>(null);
const [connectorName, setConnectorName] = useState("");
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
const [csvParsedData, setCsvParsedData] = useState<Record<string, string>[]>([]);
const [enumValidationErrors, setEnumValidationErrors] = useState<TEnumValidationError[]>([]);
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
const [responseCountBySurvey, setResponseCountBySurvey] = useState<Record<string, number | null>>({});
const [importHistoricalBySurvey, setImportHistoricalBySurvey] = useState<Record<string, boolean>>({});
const [isImporting, setIsImporting] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const fetchResponseCount = useCallback(
async (surveyId: string) => {
if (responseCountBySurvey[surveyId] !== undefined) return;
try {
const result = await getResponseCountAction({ surveyId, environmentId });
if (result?.data !== undefined) {
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: result.data ?? null }));
}
} catch {
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: null }));
}
},
[environmentId, responseCountBySurvey]
);
useEffect(() => {
if (selectedSurveyId && selectedType === "formbricks") {
fetchResponseCount(selectedSurveyId);
}
}, [selectedSurveyId, selectedType, fetchResponseCount]);
const resetForm = () => {
setCurrentStep("selectType");
setSelectedType(null);
setConnectorName("");
setMappings([]);
setSourceFields([]);
setCsvParsedData([]);
setEnumValidationErrors([]);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
setResponseCountBySurvey({});
setImportHistoricalBySurvey({});
setIsImporting(false);
setIsCreating(false);
};
const handleOpenChange = (newOpen: boolean) => {
if (isImporting) return;
if (!newOpen) resetForm();
onOpenChange(newOpen);
};
const handleNextStep = () => {
if (currentStep !== "selectType" || !selectedType) return;
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
setConnectorName(
selectedType === "formbricks" && selectedSurvey
? `${selectedSurvey.name} ${t("environments.unify.connection")}`
: defaultConnectorName[selectedType]
);
setCurrentStep("mapping");
};
const handleSurveySelect = (surveyId: string | null) => {
setSelectedSurveyId(surveyId);
};
const handleElementToggle = (elementId: string) => {
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => {
const current = prev[selectedSurveyId] ?? [];
return {
...prev,
[selectedSurveyId]: current.includes(elementId)
? current.filter((id) => id !== elementId)
: [...current, elementId],
};
});
};
const handleSelectAllElements = (surveyId: string) => {
const survey = surveys.find((s) => s.id === surveyId);
if (survey) {
setElementIdsBySurvey((prev) => ({
...prev,
[surveyId]: survey.elements
.filter((e) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(e.type))
.map((e) => e.id),
}));
}
};
const handleDeselectAllElements = () => {
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => ({
...prev,
[selectedSurveyId]: [],
}));
};
const handleBack = () => {
if (currentStep === "mapping") {
setCurrentStep("selectType");
setMappings([]);
setSourceFields([]);
}
};
const getSurveyMappings = () =>
Object.entries(elementIdsBySurvey)
.filter(([, ids]) => ids.length > 0)
.map(([surveyId, elementIds]) => ({ surveyId, elementIds }));
const handleHistoricalImports = async (connectorId: string) => {
const surveysToImport = Object.entries(importHistoricalBySurvey)
.filter(([surveyId, checked]) => checked && (elementIdsBySurvey[surveyId]?.length ?? 0) > 0)
.map(([surveyId]) => surveyId);
if (surveysToImport.length === 0) return;
setIsImporting(true);
let totalSuccesses = 0;
let totalFailures = 0;
let totalSkipped = 0;
for (const surveyId of surveysToImport) {
const importResult = await importHistoricalResponsesAction({
connectorId,
environmentId,
surveyId,
});
if (importResult?.data) {
totalSuccesses += importResult.data.successes;
totalFailures += importResult.data.failures;
totalSkipped += importResult.data.skipped;
} else {
toast.error(getFormattedErrorMessage(importResult));
}
}
setIsImporting(false);
if (totalSuccesses > 0 || totalFailures > 0) {
toast.success(
t("environments.unify.historical_import_complete", {
successes: totalSuccesses,
failures: totalFailures,
skipped: totalSkipped,
})
);
}
};
const handleCsvImport = async (connectorId: string) => {
setIsImporting(true);
const importResult = await importCsvDataAction({
connectorId,
environmentId,
csvData: csvParsedData,
});
setIsImporting(false);
if (importResult?.data) {
toast.success(
t("environments.unify.csv_import_complete", {
successes: importResult.data.successes,
failures: importResult.data.failures,
skipped: importResult.data.skipped,
})
);
} else {
toast.error(getFormattedErrorMessage(importResult));
}
};
const handleCreate = async () => {
if (!selectedType || !connectorName.trim()) return;
if (selectedType === "csv" && csvParsedData.length > 0) {
const errors = validateEnumMappings(mappings, csvParsedData);
if (errors.length > 0) {
setEnumValidationErrors(errors);
return;
}
setEnumValidationErrors([]);
}
setIsCreating(true);
const surveyMappings = getSurveyMappings();
const connectorId = await onCreateConnector({
name: connectorName.trim(),
type: selectedType,
surveyMappings: selectedType === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
fieldMappings: selectedType !== "formbricks" && mappings.length > 0 ? mappings : undefined,
});
if (connectorId && selectedType === "formbricks") {
await handleHistoricalImports(connectorId);
}
if (connectorId && selectedType === "csv" && csvParsedData.length > 0) {
await handleCsvImport(connectorId);
}
setIsCreating(false);
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 hasAnyElementSelections = Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
const isFormbricksValid = selectedType === "formbricks" && hasAnyElementSelections;
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 (
<>
<Button onClick={() => onOpenChange(true)} size="sm">
{t("environments.unify.add_source")}
<PlusIcon className="ml-2 h-4 w-4" />
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
{isImporting && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg bg-white/80">
<div className="flex flex-col items-center gap-3">
<Loader2Icon className="h-8 w-8 animate-spin text-slate-500" />
<p className="text-sm font-medium text-slate-700">
{t("environments.unify.importing_historical_data")}
</p>
</div>
</div>
)}
<DialogHeader>
<DialogTitle>{getDialogTitle(currentStep, selectedType, t)}</DialogTitle>
<DialogDescription>{getDialogDescription(currentStep, selectedType, t)}</DialogDescription>
</DialogHeader>
<div className="py-4">
{currentStep === "selectType" && (
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
)}
{currentStep === "mapping" && selectedType === "formbricks" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="connectorName">{t("environments.unify.source_name")}</Label>
<Input
id="connectorName"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
placeholder={t("environments.unify.enter_name_for_source")}
/>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<FormbricksSurveySelector
surveys={surveys}
selectedSurveyId={selectedSurveyId}
selectedElementIds={selectedElementIds}
onSurveySelect={handleSurveySelect}
onElementToggle={handleElementToggle}
onSelectAllElements={handleSelectAllElements}
onDeselectAllElements={handleDeselectAllElements}
/>
</div>
{(() => {
const entries = Object.entries(elementIdsBySurvey)
.filter(([, ids]) => ids.length > 0)
.map(([surveyId, ids]) => ({
surveyId,
surveyName: surveys.find((s) => s.id === surveyId)?.name ?? surveyId,
responseCount: responseCountBySurvey[surveyId] ?? 0,
elementCount: ids.length,
importHistorical: importHistoricalBySurvey[surveyId] ?? false,
}))
.filter((e) => e.responseCount > 0);
if (entries.length === 0) return null;
return (
<AggregateImportSection
surveyEntries={entries}
onImportHistoricalChange={(surveyId, checked) => {
setImportHistoricalBySurvey((prev) => ({ ...prev, [surveyId]: checked }));
}}
t={t}
/>
);
})()}
</div>
)}
{currentStep === "mapping" && selectedType === "csv" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="connectorName">{t("environments.unify.source_name")}</Label>
<Input
id="connectorName"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
placeholder={t("environments.unify.enter_name_for_source")}
/>
</div>
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<CsvConnectorUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={(m) => {
setMappings(m);
setEnumValidationErrors([]);
}}
onSourceFieldsChange={setSourceFields}
onLoadSampleCSV={handleLoadSourceFields}
onParsedDataChange={setCsvParsedData}
/>
</div>
{enumValidationErrors.length > 0 && (
<Alert variant="error" size="small">
{enumValidationErrors.map((err) => {
const uniqueValues = [...new Set(err.invalidEntries.map((e) => e.value))];
const rowNumbers = err.invalidEntries.slice(0, 5).map((e) => e.row);
return (
<div key={err.targetFieldName} className="text-xs">
<p className="font-medium">
{t("environments.unify.invalid_enum_values", {
field: err.targetFieldName,
})}
</p>
<p>
{t("environments.unify.invalid_values_found", {
values: uniqueValues.join(", "),
rows: rowNumbers.join(", "),
extra: err.invalidEntries.length > 5 ? `+${err.invalidEntries.length - 5}` : "",
})}
</p>
<p className="mt-1 text-slate-500">
{t("environments.unify.allowed_values", {
values: err.allowedValues.join(", "),
})}
</p>
</div>
);
})}
</Alert>
)}
</div>
)}
</div>
<DialogFooter>
{currentStep === "mapping" && (
<Button variant="outline" onClick={handleBack} disabled={isCreating || isImporting}>
{t("common.back")}
</Button>
)}
{currentStep === "selectType" ? (
<Button onClick={handleNextStep} disabled={!selectedType}>
{getNextStepButtonLabel(selectedType, t)}
</Button>
) : (
<Button
onClick={handleCreate}
disabled={
isCreating ||
isImporting ||
!connectorName.trim() ||
getCreateDisabled(selectedType, !!isFormbricksValid, isCsvValid, allRequiredMapped)
}>
{isCreating && <Loader2Icon className="mr-2 h-4 w-4 animate-spin" />}
{t("environments.unify.setup_connection")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -1,220 +0,0 @@
"use client";
import { parse } from "csv-parse/sync";
import { ArrowUpFromLineIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { validateCsvFile } from "@/app/(app)/environments/[environmentId]/workspace/unify/sources/utils";
import { Alert } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
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;
onParsedDataChange?: (data: Record<string, string>[]) => void;
}
export function CsvConnectorUI({
sourceFields,
mappings,
onMappingsChange,
onSourceFieldsChange,
onLoadSampleCSV,
onParsedDataChange,
}: CsvConnectorUIProps) {
const { t } = useTranslation();
const [csvFile, setCsvFile] = useState<File | null>(null);
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
const [showMapping, setShowMapping] = useState(false);
const [csvError, setCsvError] = useState("");
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target?.files?.[0];
if (file) {
processCSVFile(file);
}
};
const processCSVFile = (file: File) => {
setCsvError("");
const validateCSVFileResult = validateCsvFile(file, t);
if (!validateCSVFileResult.valid) {
setCsvError(validateCSVFileResult.error);
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);
onParsedDataChange?.(validRecords);
setShowMapping(true);
} catch (error) {
const message = error instanceof Error ? error.message : t("common.failed_to_parse_csv");
setCsvError(message);
}
};
reader.readAsText(file);
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
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 (
<div className="space-y-4">
{csvFile && (
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-800">{csvFile.name}</span>
<Badge text={`${csvPreview.length - 1} rows`} type="gray" size="tiny" />
</div>
<Button
variant="secondary"
size="sm"
onClick={() => {
setCsvFile(null);
setCsvPreview([]);
setCsvError("");
setShowMapping(false);
onSourceFieldsChange([]);
onParsedDataChange?.([]);
}}>
{t("environments.unify.change_file")}
</Button>
</div>
)}
{csvPreview.length > 0 && (
<div className="overflow-hidden rounded-lg border border-slate-200">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
{csvPreview[0]?.map((header, i) => (
<th key={i} className="px-3 py-2 text-left font-medium text-slate-700">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{csvPreview.slice(1, 4).map((row, rowIndex) => (
<tr key={rowIndex} className="border-t border-slate-100">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="px-3 py-2 text-slate-600">
{cell || <span className="text-slate-300"></span>}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{csvPreview.length > 4 && (
<div className="border-t border-slate-100 bg-slate-50 px-3 py-1.5 text-center text-xs text-slate-500">
{t("environments.unify.showing_rows", { count: csvPreview.length - 1 })}
</div>
)}
</div>
)}
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={onMappingsChange}
connectorType="csv"
/>
</div>
);
}
return (
<div className="space-y-4">
{csvError && (
<Alert variant="error" size="small">
{csvError}
</Alert>
)}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">{t("environments.unify.upload_csv_file")}</h4>
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
<label
htmlFor="csv-file-upload"
className="flex cursor-pointer flex-col items-center justify-center"
onDragOver={handleDragOver}
onDrop={handleDrop}>
<ArrowUpFromLineIcon className="h-8 w-8 text-slate-400" />
<p className="mt-2 text-sm text-slate-600">
<span className="font-semibold">{t("environments.unify.click_to_upload")}</span>{" "}
{t("environments.unify.or_drag_and_drop")}
</p>
<p className="mt-1 text-xs text-slate-400">{t("environments.unify.csv_files_only")}</p>
<input
type="file"
id="csv-file-upload"
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
</label>
</div>
<div className="flex justify-between">
<Button variant="secondary" size="sm" onClick={handleLoadSample}>
{t("environments.unify.load_sample_csv")}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,169 +0,0 @@
"use client";
import { parse } from "csv-parse/sync";
import { ArrowUpFromLineIcon, Loader2Icon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { validateCsvFile } from "@/app/(app)/environments/[environmentId]/workspace/unify/sources/utils";
import { importCsvDataAction } from "@/lib/connector/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { createFeedbackCSVDataSchema } from "../types";
interface CsvImportSectionProps {
connectorId: string;
environmentId: string;
onImportComplete?: () => void;
}
export function CsvImportSection({ connectorId, environmentId, onImportComplete }: CsvImportSectionProps) {
const { t } = useTranslation();
const [csvFile, setCsvFile] = useState<File | null>(null);
const [rowCount, setRowCount] = useState(0);
const [parsedData, setParsedData] = useState<Record<string, string>[]>([]);
const [csvError, setCsvError] = useState("");
const [isImporting, setIsImporting] = useState(false);
const processCSVFile = (file: File) => {
setCsvError("");
const validateCSVFileResult = validateCsvFile(file, t);
if (!validateCSVFileResult.valid) {
setCsvError(validateCSVFileResult.error);
return;
}
file
.text()
.then((csv) => {
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;
}
setCsvFile(file);
setParsedData(result.data);
setRowCount(result.data.length);
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : t("common.failed_to_parse_csv");
setCsvError(message);
});
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target?.files?.[0];
if (file) processCSVFile(file);
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files[0];
if (file) processCSVFile(file);
};
const handleImport = async () => {
if (parsedData.length === 0) return;
setIsImporting(true);
const result = await importCsvDataAction({ connectorId, environmentId, csvData: parsedData });
setIsImporting(false);
if (result?.data) {
toast.success(
t("environments.unify.csv_import_complete", {
successes: result.data.successes,
failures: result.data.failures,
skipped: result.data.skipped,
})
);
setCsvFile(null);
setParsedData([]);
setRowCount(0);
onImportComplete?.();
} else {
toast.error(getFormattedErrorMessage(result));
}
};
const handleClear = () => {
setCsvFile(null);
setParsedData([]);
setRowCount(0);
setCsvError("");
};
return (
<div className="space-y-3">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<p className="text-xs text-amber-800">{t("environments.unify.csv_import_duplicate_warning")}</p>
</div>
{csvError && (
<Alert variant="error" size="small">
{csvError}
</Alert>
)}
{csvFile ? (
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-800">{csvFile.name}</span>
<Badge text={`${rowCount} rows`} type="gray" size="tiny" />
</div>
<Button variant="secondary" size="sm" onClick={handleClear} disabled={isImporting}>
{t("environments.unify.change_file")}
</Button>
</div>
<Button onClick={handleImport} disabled={isImporting} className="w-full">
{isImporting ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
{t("environments.unify.importing_data")}
</>
) : (
t("environments.unify.import_rows", { count: rowCount })
)}
</Button>
</div>
) : (
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
<label
htmlFor="csv-import-upload"
className="flex cursor-pointer flex-col items-center justify-center"
onDragOver={handleDragOver}
onDrop={handleDrop}>
<ArrowUpFromLineIcon className="h-8 w-8 text-slate-400" />
<p className="mt-2 text-sm text-slate-600">
<span className="font-semibold">{t("environments.unify.click_to_upload")}</span>{" "}
{t("environments.unify.or_drag_and_drop")}
</p>
<p className="mt-1 text-xs text-slate-400">{t("environments.unify.csv_files_only")}</p>
<input
type="file"
id="csv-import-upload"
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
</label>
</div>
)}
</div>
);
}

View File

@@ -1,281 +0,0 @@
"use client";
import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react";
import { useEffect, useMemo, 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 {
FEEDBACK_RECORD_FIELDS,
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;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => Promise<void>;
surveys: TUnifySurvey[];
}
const getConnectorIcon = (type: TConnectorType) => {
switch (type) {
case "formbricks":
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-5 w-5 text-slate-500" />;
default:
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
}
};
const getConnectorTypeLabelKey = (type: TConnectorType): string => {
switch (type) {
case "formbricks":
return "environments.unify.formbricks_surveys";
case "csv":
return "environments.unify.csv_import";
default:
return type;
}
};
const groupMappingsBySurvey = (
mappings: { surveyId: string; elementId: string }[]
): Record<string, string[]> => {
const grouped: Record<string, string[]> = {};
for (const m of mappings) {
if (!grouped[m.surveyId]) grouped[m.surveyId] = [];
grouped[m.surveyId].push(m.elementId);
}
return grouped;
};
export const EditConnectorModal = ({
connector,
open,
onOpenChange,
onUpdateConnector,
surveys,
}: EditConnectorModalProps) => {
const { t } = useTranslation();
const [connectorName, setConnectorName] = useState("");
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
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))
);
useEffect(() => {
if (connector) {
setConnectorName(connector.name);
if (connector.type === "formbricks") {
const fbMappings = connector.formbricksMappings;
setSelectedSurveyId(fbMappings.length > 0 ? fbMappings[0].surveyId : null);
setElementIdsBySurvey(groupMappingsBySurvey(fbMappings));
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);
setElementIdsBySurvey({});
} else {
setSourceFields([]);
setMappings([]);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
}
}
}, [connector]);
const resetForm = () => {
setConnectorName("");
setMappings([]);
setSourceFields([]);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm();
}
onOpenChange(newOpen);
};
const handleSurveySelect = (surveyId: string | null) => {
setSelectedSurveyId(surveyId);
};
const handleElementToggle = (elementId: string) => {
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => {
const current = prev[selectedSurveyId] ?? [];
return {
...prev,
[selectedSurveyId]: current.includes(elementId)
? current.filter((id) => id !== elementId)
: [...current, elementId],
};
});
};
const handleSelectAllElements = (surveyId: string) => {
const survey = surveys.find((s) => s.id === surveyId);
if (survey) {
setElementIdsBySurvey((prev) => ({
...prev,
[surveyId]: survey.elements.map((e) => e.id),
}));
}
};
const handleDeselectAllElements = () => {
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => ({
...prev,
[selectedSurveyId]: [],
}));
};
const handleUpdate = async () => {
if (!connector || !connectorName.trim()) return;
const surveyMappings = Object.entries(elementIdsBySurvey)
.filter(([, ids]) => ids.length > 0)
.map(([surveyId, elementIds]) => ({ surveyId, elementIds }));
await onUpdateConnector({
connectorId: connector.id,
environmentId: connector.environmentId,
name: connectorName.trim(),
surveyMappings:
connector.type === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
fieldMappings: connector.type !== "formbricks" && mappings.length > 0 ? mappings : undefined,
});
handleOpenChange(false);
};
const saveChangesDisbaled = useMemo(() => {
if (!connector) return true;
if (!connectorName.trim()) return true;
if (connector.type === "formbricks") {
return !Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
}
if (connector.type === "csv") {
return !allRequiredMapped;
}
}, [allRequiredMapped, connector, connectorName, elementIdsBySurvey]);
if (!connector) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t("environments.unify.edit_source_connection")}</DialogTitle>
<DialogDescription>{t("environments.unify.update_mapping_description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
{getConnectorIcon(connector.type)}
<div>
<p className="text-sm font-medium text-slate-900">
{t(getConnectorTypeLabelKey(connector.type))}
</p>
<p className="text-xs text-slate-500">
{t("environments.unify.source_type_cannot_be_changed")}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="editConnectorName">{t("environments.unify.source_name")}</Label>
<Input
id="editConnectorName"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
placeholder={t("environments.unify.enter_name_for_source")}
/>
</div>
{connector.type === "formbricks" ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<FormbricksSurveySelector
surveys={surveys}
selectedSurveyId={selectedSurveyId}
selectedElementIds={selectedElementIds}
onSurveySelect={handleSurveySelect}
onElementToggle={handleElementToggle}
onSelectAllElements={handleSelectAllElements}
onDeselectAllElements={handleDeselectAllElements}
/>
</div>
) : (
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
connectorType={connector.type}
/>
</div>
)}
</div>
<DialogFooter>
<Button onClick={handleUpdate} disabled={saveChangesDisbaled}>
{t("environments.unify.save_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,241 +0,0 @@
"use client";
import { CheckIcon, ChevronRightIcon, FileTextIcon, MessageSquareTextIcon, StarIcon } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
import { Badge } from "@/modules/ui/components/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
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;
}
const getElementIcon = (type: TSurveyElementTypeEnum) => {
switch (type) {
case "openText":
return <MessageSquareTextIcon className="h-4 w-4 text-slate-500" />;
case "rating":
case "nps":
return <StarIcon className="h-4 w-4 text-amber-500" />;
default:
return <FileTextIcon className="h-4 w-4 text-slate-500" />;
}
};
const isUnsupportedType = (type: TSurveyElementTypeEnum): boolean => {
return UNSUPPORTED_CONNECTOR_ELEMENT_TYPES.includes(type);
};
export const FormbricksSurveySelector = ({
surveys,
selectedSurveyId,
selectedElementIds,
onSurveySelect,
onElementToggle,
onSelectAllElements,
onDeselectAllElements,
}: FormbricksSurveySelectorProps) => {
const { t } = useTranslation();
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
const supportedElements = selectedSurvey?.elements.filter((e) => !isUnsupportedType(e.type)) ?? [];
const allSupportedSelected =
supportedElements.length > 0 && supportedElements.every((e) => selectedElementIds.includes(e.id));
const handleSurveyClick = (survey: TUnifySurvey) => {
if (selectedSurveyId !== survey.id) {
onSurveySelect(survey.id);
}
};
const handleSelectAllSupported = (surveyId: string) => {
onSelectAllElements(surveyId);
};
const getStatusBadge = (status: TUnifySurvey["status"]) => {
switch (status) {
case "active":
return <Badge text={t("environments.unify.status_active")} type="success" size="tiny" />;
case "paused":
return <Badge text={t("environments.unify.status_paused")} type="warning" size="tiny" />;
case "draft":
return <Badge text={t("environments.unify.status_draft")} type="gray" size="tiny" />;
case "completed":
return <Badge text={t("environments.unify.status_completed")} type="gray" size="tiny" />;
default:
return null;
}
};
const getSupportedElementCount = (survey: TUnifySurvey) =>
survey.elements.filter((e) => !isUnsupportedType(e.type)).length;
const getElementButtonClassName = (unsupported: boolean, isSelected: boolean): string => {
if (unsupported) return "cursor-not-allowed border-slate-100 bg-slate-50 opacity-50";
if (isSelected) return "border-green-300 bg-green-50";
return "border-slate-200 bg-white hover:border-slate-300";
};
const getCheckboxClassName = (unsupported: boolean, isSelected: boolean): string => {
if (unsupported) return "border border-slate-200 bg-slate-100";
if (isSelected) return "bg-green-500 text-white";
return "border border-slate-300 bg-white";
};
const renderElementPanel = () => {
if (!selectedSurvey) {
return (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("environments.unify.select_a_survey_to_see_questions")}</p>
</div>
);
}
if (selectedSurvey.elements.length === 0) {
return (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("environments.unify.survey_has_no_questions")}</p>
</div>
);
}
return (
<div className="space-y-2 overflow-y-auto pr-1">
<TooltipProvider delayDuration={200}>
{selectedSurvey.elements.map((element) => {
const isSelected = selectedElementIds.includes(element.id);
const unsupported = isUnsupportedType(element.type);
const button = (
<button
key={element.id}
type="button"
disabled={unsupported}
onClick={() => onElementToggle(element.id)}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${getElementButtonClassName(unsupported, isSelected)}`}>
<div
className={`flex h-5 w-5 items-center justify-center rounded ${getCheckboxClassName(unsupported, isSelected)}`}>
{isSelected && !unsupported && <CheckIcon className="h-3 w-3" />}
</div>
<div className="flex items-center gap-2">{getElementIcon(element.type)}</div>
<div className="flex-1">
<p className={`text-sm ${unsupported ? "text-slate-400" : "text-slate-900"}`}>
{element.headline}
</p>
<span className={`text-xs ${unsupported ? "text-slate-300" : "text-slate-500"}`}>
{getTSurveyElementTypeEnumName(element.type, t) ?? element.type}
</span>
</div>
</button>
);
if (unsupported) {
return (
<Tooltip key={element.id}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{t("environments.unify.question_type_not_supported")}</TooltipContent>
</Tooltip>
);
}
return button;
})}
</TooltipProvider>
{selectedElementIds.length > 0 && (
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
<p className="text-xs text-blue-700">
<Trans
i18nKey={
selectedElementIds.length === 1
? "environments.unify.question_selected"
: "environments.unify.questions_selected"
}
values={{ count: selectedElementIds.length }}
components={{ strong: <strong /> }}
/>
</p>
</div>
)}
</div>
);
};
return (
<div className="grid h-[50vh] grid-cols-2 gap-6">
{/* Left: Survey List */}
<div className="flex flex-col gap-3 overflow-hidden">
<h4 className="shrink-0 text-sm font-medium text-slate-700">
{t("environments.unify.select_survey")}
</h4>
<div className="space-y-2 overflow-y-auto pr-1">
{surveys.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("environments.unify.no_surveys_found")}</p>
</div>
) : (
surveys.map((survey) => {
const isSelected = selectedSurveyId === survey.id;
return (
<div key={survey.id}>
<button
type="button"
onClick={() => handleSurveyClick(survey)}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
isSelected
? "border-brand-dark bg-slate-50"
: "border-slate-200 bg-white hover:border-slate-300"
}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-900">{survey.name}</span>
{getStatusBadge(survey.status)}
</div>
<p className="text-xs text-slate-500">
{t("environments.unify.n_supported_questions", {
count: getSupportedElementCount(survey),
})}
</p>
</div>
{isSelected && <ChevronRightIcon className="text-brand-dark h-5 w-5 shrink-0" />}
</button>
</div>
);
})
)}
</div>
</div>
{/* Right: Element Selection */}
<div className="flex flex-col gap-3 overflow-hidden">
<div className="flex shrink-0 items-center justify-between">
<h4 className="text-sm font-medium text-slate-700">{t("environments.unify.select_questions")}</h4>
{selectedSurvey && supportedElements.length > 0 && (
<button
type="button"
onClick={() =>
allSupportedSelected ? onDeselectAllElements() : handleSelectAllSupported(selectedSurvey.id)
}
className="text-xs text-slate-500 hover:text-slate-700">
{allSupportedSelected
? t("environments.unify.deselect_all")
: t("environments.unify.select_all")}
</button>
)}
</div>
{renderElementPanel()}
</div>
</div>
);
};

View File

@@ -1,329 +0,0 @@
"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 { cn } from "@/modules/ui/lib/utils";
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 (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`flex cursor-grab items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isDragging
? "border-brand-dark bg-slate-100 opacity-50"
: isMapped
? "border-green-300 bg-green-50 text-green-800"
: "border-slate-200 bg-white hover:border-slate-300"
}`}>
<GripVerticalIcon className="h-4 w-4 text-slate-400" />
<div className="flex-1 truncate">
<span className="font-medium">{field.name}</span>
<span className="ml-2 text-xs text-slate-500">({field.type})</span>
</div>
{field.sampleValue && (
<span className="max-w-24 truncate text-xs text-slate-400">{field.sampleValue}</span>
)}
</div>
);
}
interface DroppableTargetFieldProps {
field: TTargetField;
mappedSourceField: TSourceField | null;
mapping: TFieldMapping | null;
onRemoveMapping: () => void;
onStaticValueChange: (value: string) => void;
isOver?: boolean;
}
export const 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 - support both column mapping and static dropdown
if (field.type === "enum" && field.enumValues) {
return (
<div
ref={setNodeRef}
className={cn(
`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isActive
? "border-brand-dark bg-slate-100"
: hasMapping
? "border-green-300 bg-green-50"
: "border-dashed border-slate-300 bg-slate-50"
}`
)}>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
<span className="text-xs text-slate-400">{t("environments.unify.enum")}</span>
</div>
{mappedSourceField && !mapping?.staticValue ? (
<div className="flex items-center gap-1">
<span className="text-xs text-green-700">&larr; {mappedSourceField.name}</span>
<button
type="button"
onClick={onRemoveMapping}
className="ml-1 rounded p-0.5 hover:bg-green-100">
<XIcon className="h-3 w-3 text-green-600" />
</button>
</div>
) : (
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
<SelectTrigger className="h-8 w-full bg-white">
<SelectValue placeholder={t("environments.unify.select_a_value")} />
</SelectTrigger>
<SelectContent>
{field.enumValues.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
);
}
// Handle string fields - allow drag & drop OR static value
if (field.type === "string") {
return (
<div
ref={setNodeRef}
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isActive
? "border-brand-dark bg-slate-100"
: hasMapping
? "border-green-300 bg-green-50"
: "border-dashed border-slate-300 bg-slate-50"
}`}>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
</div>
{/* Show mapped source field */}
{mappedSourceField && !mapping?.staticValue && (
<div className="flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<button
type="button"
onClick={onRemoveMapping}
className="ml-1 rounded p-0.5 hover:bg-green-100">
<XIcon className="h-3 w-3 text-green-600" />
</button>
</div>
)}
{/* Show static value */}
{mapping?.staticValue && !mappedSourceField && (
<div className="flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= &ldquo;{mapping.staticValue}&rdquo;
</span>
<button
type="button"
onClick={onRemoveMapping}
className="ml-1 rounded p-0.5 hover:bg-blue-100">
<XIcon className="h-3 w-3 text-blue-600" />
</button>
</div>
)}
{/* Show input for entering static value when editing */}
{isEditingStatic && !hasMapping && (
<div className="flex items-center gap-1">
<Input
type="text"
value={customValue}
onChange={(e) => 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);
}
}}
/>
<button
type="button"
onClick={() => {
if (customValue.trim()) {
onStaticValueChange(customValue.trim());
setCustomValue("");
}
setIsEditingStatic(false);
}}
className="rounded p-1 text-slate-500 hover:bg-slate-200">
<ChevronDownIcon className="h-3 w-3" />
</button>
</div>
)}
{/* Show example values as quick select OR drop zone */}
{!hasMapping && !isEditingStatic && (
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">{t("environments.unify.drop_field_or")}</span>
<button
type="button"
onClick={() => setIsEditingStatic(true)}
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-slate-500 hover:bg-slate-200">
<PencilIcon className="h-3 w-3" />
{t("environments.unify.set_value")}
</button>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.slice(0, 3).map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{val}
</button>
))}
</>
)}
</div>
)}
</div>
</div>
);
}
// 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 (
<div
ref={setNodeRef}
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isActive
? "border-brand-dark bg-slate-100"
: hasDefaultMapping
? "border-green-300 bg-green-50"
: "border-dashed border-slate-300 bg-slate-50"
}`}>
<div className="flex flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
<span className="text-xs text-slate-400">({field.type})</span>
</div>
{/* Show mapped source field */}
{mappedSourceField && !mapping?.staticValue && (
<div className="mt-1 flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-green-100">
<XIcon className="h-3 w-3 text-green-600" />
</button>
</div>
)}
{/* Show static value */}
{mapping?.staticValue && !mappedSourceField && (
<div className="mt-1 flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= {getStaticValueLabel(mapping.staticValue)}
</span>
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-blue-100">
<XIcon className="h-3 w-3 text-blue-600" />
</button>
</div>
)}
{/* Show drop zone with preset options */}
{!hasDefaultMapping && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">{t("environments.unify.drop_a_field_here")}</span>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{getStaticValueLabel(val)}
</button>
))}
</>
)}
</div>
)}
</div>
</div>
);
};

View File

@@ -1,148 +0,0 @@
"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<string | null>(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 (
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="grid grid-cols-2 gap-6">
{/* Source Fields Panel */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">
{connectorType === "csv"
? t("environments.unify.csv_columns")
: t("environments.unify.source_fields")}
</h4>
{sourceFields.length === 0 ? (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">
{connectorType === "csv"
? t("environments.unify.click_load_sample_csv")
: t("environments.unify.no_source_fields_loaded")}
</p>
</div>
) : (
<div className="space-y-2">
{sourceFields.map((field) => (
<DraggableSourceField key={field.id} field={field} isMapped={isSourceFieldMapped(field.id)} />
))}
</div>
)}
</div>
{/* Target Fields Panel */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">
{t("environments.unify.feedback_record_fields")}
</h4>
{/* Required Fields */}
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{t("environments.unify.required")}
</p>
{requiredFields.map((field) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
/>
))}
</div>
{/* Optional Fields */}
<div className="mt-4 space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{t("environments.unify.optional")}
</p>
{optionalFields.map((field) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
/>
))}
</div>
</div>
</div>
<DragOverlay>
{activeField ? (
<div className="border-brand-dark rounded-md border bg-white p-2 text-sm shadow-lg">
<span className="font-medium">{activeField.name}</span>
<span className="ml-2 text-xs text-slate-500">({activeField.type})</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}

View File

@@ -1,243 +0,0 @@
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<string, string>, _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<string, string>) => headline,
}));
const NOW = new Date("2026-02-24T10:00:00.000Z");
const createMockSurvey = (overrides: Partial<TSurvey> = {}): 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<TSurvey>);
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<TSurvey>);
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<TSurvey>);
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<TSurvey>));
expect(result.status).toBe("active");
});
test("maps 'paused' to 'paused'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "paused" } as Partial<TSurvey>));
expect(result.status).toBe("paused");
});
test("maps 'draft' to 'draft'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "draft" } as Partial<TSurvey>));
expect(result.status).toBe("draft");
});
test("maps 'completed' to 'completed'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "completed" } as Partial<TSurvey>));
expect(result.status).toBe("completed");
});
test("maps unknown status to 'draft'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "archived" } as Partial<TSurvey>));
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<TSurvey>);
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<TSurvey>);
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<TSurvey>);
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);
});
});

View File

@@ -1,51 +0,0 @@
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,
};
};

View File

@@ -1,26 +0,0 @@
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 (
<ConnectorsSection
environmentId={params.environmentId}
initialConnectors={connectors}
initialSurveys={unifySurveys}
/>
);
}

View File

@@ -1,212 +0,0 @@
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",
},
{
id: "source_type",
name: "Source Type",
type: "string",
required: true,
description: "Type of source (e.g., survey, review, support)",
},
{
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",
},
{
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<ReturnType<typeof createFeedbackCSVDataSchema>>;
export type TCreateConnectorStep = "selectType" | "mapping";

View File

@@ -1,111 +0,0 @@
import { describe, expect, test } from "vitest";
import { MAX_CSV_VALUES, TSourceField } from "./types";
import { getConnectorOptions, parseCSVColumnsToFields, validateCsvFile } 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<TSourceField[]>([
{ 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");
});
});
const createMockFile = (name: string, size: number, type: string): File =>
new File(["x".repeat(size)], name, { type });
describe("validateCsvFile", () => {
test("accepts a valid .csv file", () => {
const file = createMockFile("data.csv", 1024, "text/csv");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: true });
});
test("rejects a file without .csv extension", () => {
const file = createMockFile("data.xlsx", 1024, "text/csv");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: false, error: "environments.unify.csv_files_only" });
});
test("rejects a file with wrong MIME type", () => {
const file = createMockFile("data.csv", 1024, "application/json");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: false, error: "environments.unify.csv_files_only" });
});
test("accepts a .csv file with empty MIME type", () => {
const file = createMockFile("data.csv", 1024, "");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: true });
});
test("accepts a .csv file with alternative csv MIME type", () => {
const file = createMockFile("report.csv", 512, "application/csv");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: true });
});
test("rejects a file exceeding the size limit", () => {
const file = createMockFile("big.csv", MAX_CSV_VALUES.FILE_SIZE + 1, "text/csv");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: false, error: "environments.unify.csv_file_too_large" });
});
test("accepts a file exactly at the size limit", () => {
const file = createMockFile("exact.csv", MAX_CSV_VALUES.FILE_SIZE, "text/csv");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: true });
});
test("checks extension before MIME type", () => {
const file = createMockFile("data.txt", 100, "text/csv");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: false, error: "environments.unify.csv_files_only" });
});
});

View File

@@ -1,93 +0,0 @@
import { TFunction } from "i18next";
import { THubFieldType } from "@formbricks/types/connector";
import { FEEDBACK_RECORD_FIELDS, MAX_CSV_VALUES, TFieldMapping, 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}` };
});
};
export interface TEnumValidationError {
targetFieldName: string;
invalidEntries: { row: number; value: string }[];
allowedValues: string[];
}
/**
* Validates that CSV columns mapped to enum target fields contain only allowed values.
* Returns an array of validation errors (empty if all valid).
*/
export const validateEnumMappings = (
mappings: TFieldMapping[],
csvData: Record<string, string>[]
): TEnumValidationError[] => {
const errors: TEnumValidationError[] = [];
for (const mapping of mappings) {
if (!mapping.sourceFieldId || mapping.staticValue) continue;
const targetField = FEEDBACK_RECORD_FIELDS.find((f) => f.id === mapping.targetFieldId);
if (!targetField || targetField.type !== "enum" || !targetField.enumValues) continue;
const allowedValues = new Set(targetField.enumValues);
const invalidEntries: { row: number; value: string }[] = [];
for (let i = 0; i < csvData.length; i++) {
const value = csvData[i][mapping.sourceFieldId]?.trim();
if (value && !allowedValues.has(value as THubFieldType)) {
invalidEntries.push({ row: i + 1, value });
}
}
if (invalidEntries.length > 0) {
errors.push({
targetFieldName: targetField.name,
invalidEntries,
allowedValues: targetField.enumValues,
});
}
}
return errors;
};
export const validateCsvFile = (
file: File,
t: TFunction
): { valid: true } | { valid: false; error: string } => {
if (!file.name.endsWith(".csv")) {
return { valid: false, error: t("environments.unify.csv_files_only") };
}
if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) {
return { valid: false, error: t("environments.unify.csv_files_only") };
}
if (file.size > MAX_CSV_VALUES.FILE_SIZE) {
return { valid: false, error: t("environments.unify.csv_file_too_large") };
}
return { valid: true };
};

View File

@@ -8,7 +8,6 @@ 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";
@@ -31,10 +30,7 @@ export const POST = async (request: Request) => {
}
const jsonInput = await request.json();
const convertedJsonInput = convertDatesInObject(
jsonInput,
new Set(["contactAttributes", "variables", "data", "meta"])
);
const convertedJsonInput = convertDatesInObject(jsonInput);
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
@@ -142,14 +138,6 @@ 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),

View File

@@ -1,5 +1,4 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { responses } from "@/app/lib/api/response";
import {
GOOGLE_SHEETS_CLIENT_ID,
@@ -7,29 +6,18 @@ import {
GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL,
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
const url = new URL(req.url);
const environmentId = url.searchParams.get("state");
const code = url.searchParams.get("code");
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
if (!environmentId) {
return responses.badRequestResponse("Invalid environmentId");
}
const session = await getServerSession(authOptions);
if (!session) {
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}

View File

@@ -0,0 +1,180 @@
// Deprecated: This api route is deprecated now and will be removed in the future.
// Deprecated: This is currently only being used for the older react native SDKs. Please upgrade to the latest SDKs.
import { NextRequest, userAgent } from "next/server";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsPeopleUserIdInput, ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getContactByUserId } from "@/app/api/v1/client/[environmentId]/app/sync/lib/contact";
import { getSyncSurveys } from "@/app/api/v1/client/[environmentId]/app/sync/lib/survey";
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getActionClasses } from "@/lib/actionClass/service";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironment, updateEnvironment } from "@/lib/environment/service";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
const validateInput = (
environmentId: string,
userId: string
): { isValid: true; data: TJsPeopleUserIdInput } | { isValid: false; error: Response } => {
const inputValidation = ZJsPeopleUserIdInput.safeParse({ environmentId, userId });
if (!inputValidation.success) {
return {
isValid: false,
error: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
return { isValid: true, data: inputValidation.data };
};
const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
if (!IS_FORMBRICKS_CLOUD) return false;
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
logger.error({ environmentId }, "Organization does not exist");
// fail closed if the organization does not exist
return true;
}
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
return isLimitReached;
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string; userId: string }> };
}) => {
const params = await props.params;
try {
const { device } = userAgent(req);
// validate using zod
const validation = validateInput(params.environmentId, params.userId);
if (!validation.isValid) {
return { response: validation.error };
}
const { environmentId, userId } = validation.data;
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
const project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error("Project not found");
}
if (!environment.appSetupCompleted) {
await updateEnvironment(environment.id, { appSetupCompleted: true });
}
// check organization subscriptions and response limits
const isAppSurveyResponseLimitReached = await checkResponseLimit(environmentId);
let contact = await getContactByUserId(environmentId, userId);
if (!contact) {
contact = await prisma.contact.create({
data: {
attributes: {
create: {
attributeKey: {
connect: {
key_environmentId: {
key: "userId",
environmentId,
},
},
},
value: userId,
},
},
environment: { connect: { id: environmentId } },
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
}
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
acc[attribute.attributeKey.key] = attribute.value;
return acc;
}, {}) as Record<string, string>;
const [surveys, actionClasses] = await Promise.all([
getSyncSurveys(
environmentId,
contact.id,
contactAttributes,
device.type === "mobile" ? "phone" : "desktop"
),
getActionClasses(environmentId),
]);
const updatedProject: any = {
...project,
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(project.styling.highlightBorderColor?.light && {
highlightBorderColor: project.styling.highlightBorderColor.light,
}),
};
const language = contactAttributes["language"];
// Scenario 1: Multi language and updated trigger action classes supported.
// Use the surveys as they are.
let transformedSurveys: TSurvey[] = surveys;
// creating state object
let state = {
surveys: !isAppSurveyResponseLimitReached
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
: [],
actionClasses,
language,
project: updatedProject,
};
return {
response: responses.successResponse({ ...state }, true),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]");
return {
response: responses.internalServerErrorResponse(
"Unable to handle the request: " + error.message,
true
),
};
}
},
});

View File

@@ -0,0 +1,83 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContact } from "@/modules/ee/contacts/types/contact";
import { getContactByUserId } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
},
},
}));
const environmentId = "test-environment-id";
const userId = "test-user-id";
const contactId = "test-contact-id";
const contactMock: Partial<TContact> & {
attributes: { value: string; attributeKey: { key: string } }[];
} = {
id: contactId,
attributes: [
{ attributeKey: { key: "userId" }, value: userId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
],
};
describe("getContactByUserId", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("should return contact if found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(contactMock as any);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
expect(contact).toEqual(contactMock);
});
test("should return null if contact not found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
expect(contact).toBeNull();
});
});

View File

@@ -0,0 +1,42 @@
import "server-only";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
export const getContactByUserId = reactCache(
async (
environmentId: string,
userId: string
): Promise<{
attributes: {
value: string;
attributeKey: {
key: string;
};
}[];
id: string;
} | null> => {
const contact = await prisma.contact.findFirst({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
if (!contact) {
return null;
}
return contact;
}
);

View File

@@ -0,0 +1,323 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurveys } from "@/lib/survey/service";
import { anySurveyHasFilters } from "@/lib/survey/utils";
import { diffInDays } from "@/lib/utils/datetime";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
import { getSyncSurveys } from "./survey";
vi.mock("@/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/lib/survey/utils", () => ({
anySurveyHasFilters: vi.fn(),
}));
vi.mock("@/lib/utils/datetime", () => ({
diffInDays: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
evaluateSegment: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
findMany: vi.fn(),
},
response: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const environmentId = "test-env-id";
const contactId = "test-contact-id";
const contactAttributes = { userId: "user1", email: "test@example.com" };
const deviceType = "desktop";
const mockProject = {
id: "proj1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org1",
environments: [],
recontactDays: 10,
inAppSurveyBranding: true,
linkSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
languages: [],
} as unknown as TProject;
const baseSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey 1",
environmentId: environmentId,
type: "app",
status: "inProgress",
questions: [],
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
segment: null,
surveyClosedMessage: null,
singleUse: null,
styling: null,
pin: null,
displayLimit: null,
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
endings: [],
triggers: [],
languages: [],
variables: [],
hiddenFields: { enabled: false },
createdBy: null,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],
recaptcha: { enabled: false, threshold: 0.5 },
};
// Helper function to create mock display objects
const createMockDisplay = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({
id,
createdAt: createdAt || new Date(),
updatedAt: new Date(),
surveyId,
contactId,
responseId: null,
status: null,
});
// Helper function to create mock response objects
const createMockResponse = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({
id,
createdAt: createdAt || new Date(),
updatedAt: new Date(),
finished: false,
surveyId,
contactId,
endingId: null,
data: {},
variables: {},
ttc: {},
meta: {},
contactAttributes: null,
singleUseId: null,
language: null,
displayId: null,
});
describe("getSyncSurveys", () => {
beforeEach(() => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
vi.mocked(evaluateSegment).mockResolvedValue(true);
vi.mocked(diffInDays).mockReturnValue(100); // Assume enough days passed
});
afterEach(() => {
vi.resetAllMocks();
});
test("should throw error if product not found", async () => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
"Project not found"
);
});
test("should return empty array if no surveys found", async () => {
vi.mocked(getSurveys).mockResolvedValue([]);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
});
test("should return empty array if no 'app' type surveys in progress", async () => {
const surveys: TSurvey[] = [
{ ...baseSurvey, id: "s1", type: "link", status: "inProgress" },
{ ...baseSurvey, id: "s2", type: "app", status: "paused" },
];
vi.mocked(getSurveys).mockResolvedValue(surveys);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
});
test("should filter by displayOption 'displayOnce'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayOnce" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Already displayed
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.display.findMany).mockResolvedValue([]); // Not displayed yet
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should filter by displayOption 'displayMultiple'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayMultiple" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]); // Already responded
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.response.findMany).mockResolvedValue([]); // Not responded yet
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should filter by displayOption 'displaySome'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displaySome", displayLimit: 2 }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([
createMockDisplay("d1", "s1", contactId),
createMockDisplay("d2", "s1", contactId),
]); // Display limit reached
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Within limit
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
// Test with response already submitted
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]);
const result3 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result3).toEqual([]);
});
test("should not filter by displayOption 'respondMultiple'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "respondMultiple" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]);
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual(surveys);
});
test("should filter by product recontactDays if survey recontactDays is null", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", recontactDays: null }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
const displayDate = new Date();
vi.mocked(prisma.display.findMany).mockResolvedValue([
createMockDisplay("d1", "s2", contactId, displayDate), // Display for another survey
]);
vi.mocked(diffInDays).mockReturnValue(5); // Not enough days passed (product.recontactDays = 10)
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
expect(diffInDays).toHaveBeenCalledWith(expect.any(Date), displayDate);
vi.mocked(diffInDays).mockReturnValue(15); // Enough days passed
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should return surveys if no segment filters exist", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual(surveys);
expect(evaluateSegment).not.toHaveBeenCalled();
});
test("should evaluate segment filters if they exist", async () => {
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
// Case 1: Segment evaluation matches
vi.mocked(evaluateSegment).mockResolvedValue(true);
const result1 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result1).toEqual(surveys);
expect(evaluateSegment).toHaveBeenCalledWith(
{
attributes: contactAttributes,
deviceType,
environmentId,
contactId,
userId: contactAttributes.userId,
},
segment.filters
);
// Case 2: Segment evaluation does not match
vi.mocked(evaluateSegment).mockResolvedValue(false);
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual([]);
});
test("should handle Prisma errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(getSurveys).mockRejectedValue(prismaError);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
DatabaseError
);
expect(logger.error).toHaveBeenCalledWith(prismaError);
});
test("should handle general errors", async () => {
const generalError = new Error("Something went wrong");
vi.mocked(getSurveys).mockRejectedValue(generalError);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
generalError
);
});
test("should throw ResourceNotFoundError if resolved surveys are null after filtering", async () => {
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
vi.mocked(evaluateSegment).mockResolvedValue(false); // Ensure all surveys are filtered out
// This scenario is tricky to force directly as the code checks `if (!surveys)` before returning.
// However, if `Promise.all` somehow resolved to null/undefined (highly unlikely), it should throw.
// We can simulate this by mocking `Promise.all` if needed, but the current code structure makes this hard to test.
// Let's assume the filter logic works correctly and test the intended path.
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]); // Expect empty array, not an error in this case.
});
});

View File

@@ -0,0 +1,148 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurveys } from "@/lib/survey/service";
import { anySurveyHasFilters } from "@/lib/survey/utils";
import { diffInDays } from "@/lib/utils/datetime";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
export const getSyncSurveys = reactCache(
async (
environmentId: string,
contactId: string,
contactAttributes: Record<string, string | number>,
deviceType: "phone" | "desktop" = "desktop"
): Promise<TSurvey[]> => {
validateInputs([environmentId, ZId]);
try {
const project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error("Project not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "app");
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
}
const displays = await prisma.display.findMany({
where: {
contactId,
},
});
const responses = await prisma.response.findMany({
where: {
contactId,
},
});
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
switch (survey.displayOption) {
case "respondMultiple":
return true;
case "displayOnce":
return displays.filter((display) => display.surveyId === survey.id).length === 0;
case "displayMultiple":
if (!responses) return true;
else {
return responses.filter((response) => response.surveyId === survey.id).length === 0;
}
case "displaySome":
if (survey.displayLimit === null) {
return true;
}
if (responses && responses.filter((response) => response.surveyId === survey.id).length !== 0) {
return false;
}
return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit;
default:
throw Error("Invalid displayOption");
}
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = surveys.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (project.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= project.recontactDays;
} else {
return true;
}
});
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
}
// if no surveys have segment filters, return the surveys
if (!anySurveyHasFilters(surveys)) {
return surveys;
}
// the surveys now have segment filters, so we need to evaluate them
const surveyPromises = surveys.map(async (survey) => {
const { segment } = survey;
// if the survey has no segment, or the segment has no filters, we return the survey
if (!segment || !segment.filters?.length) {
return survey;
}
// Evaluate the segment filters
const result = await evaluateSegment(
{
attributes: contactAttributes ?? {},
deviceType,
environmentId,
contactId,
userId: String(contactAttributes.userId),
},
segment.filters
);
return result ? survey : null;
});
const resolvedSurveys = await Promise.all(surveyPromises);
surveys = resolvedSurveys.filter((survey) => !!survey) as TSurvey[];
if (!surveys) {
throw new ResourceNotFoundError("Survey", environmentId);
}
return surveys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
}
);

View File

@@ -0,0 +1,245 @@
import { describe, expect, test, vi } from "vitest";
import { TAttributes } from "@formbricks/types/attributes";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyEnding,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { parseRecallInfo } from "@/lib/utils/recall";
import { replaceAttributeRecall } from "./utils";
vi.mock("@/lib/utils/recall", () => ({
parseRecallInfo: vi.fn((text, attributes) => {
const recallPattern = /recall:([a-zA-Z0-9_-]+)/;
const match = text.match(recallPattern);
if (match && match[1]) {
const recallKey = match[1];
const attributeValue = attributes[recallKey];
if (attributeValue !== undefined) {
return text.replace(recallPattern, `parsed-${attributeValue}`);
}
}
return text; // Return original text if no match or attribute not found
}),
}));
const baseSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
environmentId: "env1",
type: "app",
status: "inProgress",
questions: [],
endings: [],
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
languages: [
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
],
triggers: [],
recontactDays: null,
displayLimit: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
hiddenFields: { enabled: false },
variables: [],
createdBy: null,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],
recaptcha: { enabled: false, threshold: 0.5 },
displayOption: "displayOnce",
autoClose: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
segment: null,
pin: null,
metadata: {},
};
const attributes: TAttributes = {
name: "John Doe",
email: "john.doe@example.com",
plan: "premium",
};
describe("replaceAttributeRecall", () => {
test("should replace recall info in question headlines and subheaders", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Hello recall:name!" },
subheader: { default: "Your email is recall:email" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
expect(result.questions[0].subheader?.default).toBe("Your email is parsed-john.doe@example.com");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your email is recall:email", attributes);
});
test("should replace recall info in welcome card headline", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome, recall:name!" },
subheader: { default: "<p>Some content</p>" },
buttonLabel: { default: "Start" },
timeToFinish: false,
showResponseCount: false,
},
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.welcomeCard.headline?.default).toBe("Welcome, parsed-John Doe!");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Welcome, recall:name!", attributes);
});
test("should replace recall info in end screen headlines and subheaders", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
endings: [
{
type: "endScreen",
headline: { default: "Thank you, recall:name!" },
subheader: { default: "Your plan: recall:plan" },
buttonLabel: { default: "Finish" },
buttonLink: "https://example.com",
} as unknown as TSurveyEnding,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.endings[0].type).toBe("endScreen");
if (result.endings[0].type === "endScreen") {
expect(result.endings[0].headline?.default).toBe("Thank you, parsed-John Doe!");
expect(result.endings[0].subheader?.default).toBe("Your plan: parsed-premium");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Thank you, recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your plan: recall:plan", attributes);
}
});
test("should handle multiple languages", () => {
const surveyMultiLang: TSurvey = {
...baseSurvey,
languages: [
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
{ language: { id: "lang2", code: "es" } as unknown as TLanguage, default: false, enabled: true },
],
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Hello recall:name!", es: "Hola recall:name!" },
required: true,
buttonLabel: { default: "Next", es: "Siguiente" },
placeholder: { default: "Type here...", es: "Escribe aquí..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyMultiLang, attributes);
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
expect(result.questions[0].headline.es).toBe("Hola parsed-John Doe!");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hola recall:name!", attributes);
});
test("should not replace if recall key is not in attributes", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Your company: recall:company" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.questions[0].headline.default).toBe("Your company: recall:company");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your company: recall:company", attributes);
});
test("should handle surveys with no recall information", async () => {
const surveyNoRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Just a regular question" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
welcomeCard: {
enabled: true,
headline: { default: "Welcome!" },
subheader: { default: "<p>Some content</p>" },
buttonLabel: { default: "Start" },
timeToFinish: false,
showResponseCount: false,
},
endings: [
{
type: "endScreen",
headline: { default: "Thank you!" },
buttonLabel: { default: "Finish" },
} as unknown as TSurveyEnding,
],
};
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
const result = replaceAttributeRecall(surveyNoRecall, attributes);
expect(result).toEqual(surveyNoRecall); // Should be unchanged
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
parseRecallInfoSpy.mockRestore();
});
test("should handle surveys with empty questions, endings, or disabled welcome card", async () => {
const surveyEmpty: TSurvey = {
...baseSurvey,
questions: [],
endings: [],
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
};
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
const result = replaceAttributeRecall(surveyEmpty, attributes);
expect(result).toEqual(surveyEmpty);
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
parseRecallInfoSpy.mockRestore();
});
});

View File

@@ -0,0 +1,55 @@
import { TAttributes } from "@formbricks/types/attributes";
import { TSurvey } from "@formbricks/types/surveys/types";
import { parseRecallInfo } from "@/lib/utils/recall";
export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes): TSurvey => {
const surveyTemp = structuredClone(survey);
const languages = surveyTemp.languages
.map((surveyLanguage) => {
if (surveyLanguage.default) {
return "default";
}
if (surveyLanguage.enabled) {
return surveyLanguage.language.code;
}
return null;
})
.filter((language): language is string => language !== null);
surveyTemp.questions.forEach((question) => {
languages.forEach((language) => {
if (question.headline[language]?.includes("recall:")) {
question.headline[language] = parseRecallInfo(question.headline[language], attributes);
}
if (question.subheader && question.subheader[language]?.includes("recall:")) {
question.subheader[language] = parseRecallInfo(question.subheader[language], attributes);
}
});
});
if (surveyTemp.welcomeCard.enabled && surveyTemp.welcomeCard.headline) {
languages.forEach((language) => {
if (surveyTemp.welcomeCard.headline && surveyTemp.welcomeCard.headline[language]?.includes("recall:")) {
surveyTemp.welcomeCard.headline[language] = parseRecallInfo(
surveyTemp.welcomeCard.headline[language],
attributes
);
}
});
}
surveyTemp.endings.forEach((ending) => {
if (ending.type === "endScreen") {
languages.forEach((language) => {
if (ending.headline && ending.headline[language]?.includes("recall:")) {
ending.headline[language] = parseRecallInfo(ending.headline[language], attributes);
if (ending.subheader && ending.subheader[language]?.includes("recall:")) {
ending.subheader[language] = parseRecallInfo(ending.subheader[language], attributes);
}
}
});
}
});
return surveyTemp;
};

View File

@@ -0,0 +1,6 @@
import {
OPTIONS,
PUT,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
export { OPTIONS, PUT };

View File

@@ -1,314 +0,0 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentStateData } from "./data";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/modules/survey/lib/utils", () => ({
transformPrismaSurvey: vi.fn((survey) => survey),
}));
const environmentId = "cjld2cjxh0000qzrmn831i7rn";
const mockEnvironmentData = {
id: environmentId,
type: "production",
appSetupCompleted: true,
project: {
id: "project-123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
},
actionClasses: [
{
id: "action-1",
type: "code",
name: "Test Action",
key: "test-action",
noCodeConfig: null,
},
],
surveys: [
{
id: "survey-1",
name: "Test Survey",
type: "app",
status: "inProgress",
welcomeCard: { enabled: false },
questions: [],
blocks: null,
variables: [],
showLanguageSwitch: false,
languages: [],
endings: [],
autoClose: null,
styling: null,
recaptcha: { enabled: false },
segment: null,
recontactDays: null,
displayLimit: null,
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
triggers: [],
displayPercentage: null,
delay: 0,
projectOverwrites: null,
},
],
};
describe("getEnvironmentStateData", () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return environment state data when environment exists", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironmentData as never);
const result = await getEnvironmentStateData(environmentId);
expect(result).toEqual({
environment: {
id: environmentId,
type: "production",
appSetupCompleted: true,
project: {
id: "project-123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
},
},
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
surveys: mockEnvironmentData.surveys,
actionClasses: mockEnvironmentData.actionClasses,
});
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: environmentId },
select: expect.objectContaining({
id: true,
type: true,
appSetupCompleted: true,
project: expect.any(Object),
actionClasses: expect.any(Object),
surveys: expect.any(Object),
}),
});
});
test("should throw ResourceNotFoundError when environment is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("environment");
});
test("should throw ResourceNotFoundError when project is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: null,
} as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when organization is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: null,
},
} as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma database errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Connection failed", {
code: "P2024",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalled();
});
test("should rethrow unexpected errors", async () => {
const unexpectedError = new Error("Unexpected error");
vi.mocked(prisma.environment.findUnique).mockRejectedValue(unexpectedError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("Unexpected error");
expect(logger.error).toHaveBeenCalled();
});
test("should handle empty surveys array", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
surveys: [],
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.surveys).toEqual([]);
});
test("should handle empty actionClasses array", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
actionClasses: [],
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.actionClasses).toEqual([]);
});
test("should transform surveys using transformPrismaSurvey", async () => {
const multipleSurveys = [
...mockEnvironmentData.surveys,
{
...mockEnvironmentData.surveys[0],
id: "survey-2",
name: "Second Survey",
},
];
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
surveys: multipleSurveys,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.surveys).toHaveLength(2);
});
test("should correctly map project properties to environment.project", async () => {
const customProject = {
...mockEnvironmentData.project,
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
placement: "center",
inAppSurveyBranding: false,
styling: { allowStyleOverwrite: true, brandColor: "#ff0000" },
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: customProject,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.project).toEqual({
id: "project-123",
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
placement: "center",
inAppSurveyBranding: false,
styling: { allowStyleOverwrite: true, brandColor: "#ff0000" },
});
});
test("should validate environmentId input", async () => {
// Invalid CUID should throw validation error
await expect(getEnvironmentStateData("invalid-id")).rejects.toThrow();
});
test("should handle different environment types", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
type: "development",
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.type).toBe("development");
});
test("should handle appSetupCompleted false", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
appSetupCompleted: false,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.appSetupCompleted).toBe(false);
});
test("should correctly extract organization billing data", async () => {
const customBilling = {
plan: "enterprise",
stripeCustomerId: "cus_123",
limits: {
monthly: { responses: 10000, miu: 50000 },
projects: 100,
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: {
id: "org-enterprise",
billing: customBilling,
},
},
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.organization).toEqual({
id: "org-enterprise",
billing: customBilling,
});
});
});

View File

@@ -54,7 +54,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: true,
recontactDays: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
placement: true,
inAppSurveyBranding: true,
styling: true,
@@ -174,7 +174,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: environmentData.project.id,
recontactDays: environmentData.project.recontactDays,
clickOutsideClose: environmentData.project.clickOutsideClose,
overlay: environmentData.project.overlay,
darkOverlay: environmentData.project.darkOverlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: environmentData.project.styling,

View File

@@ -58,7 +58,7 @@ const mockProject: TJsEnvironmentStateProject = {
inAppSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {
allowStyleOverwrite: false,
},

View File

@@ -0,0 +1,6 @@
import {
GET,
OPTIONS,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
export { GET, OPTIONS };

View File

@@ -1,15 +1,13 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -33,38 +31,6 @@ const handleDatabaseError = (error: Error, url: string, endpoint: string, respon
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
// Validate response data against validation rules
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const isFinished = responseUpdateInput.finished ?? false;
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
isFinished,
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const PUT = withV1ApiWrapper({
handler: async ({
req,
@@ -147,11 +113,6 @@ export const PUT = withV1ApiWrapper({
};
}
const validationResult = validateResponse(response, survey, inputValidation.data);
if (validationResult) {
return validationResult;
}
// update response with quota evaluation
let updatedResponse;
try {

View File

@@ -6,14 +6,12 @@ import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -35,27 +33,6 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) => {
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
@@ -146,11 +123,6 @@ export const POST = withV1ApiWrapper({
};
}
const validationResult = validateResponse(responseInputData, survey);
if (validationResult) {
return validationResult;
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInput["meta"] = {

View File

@@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import {
ENCRYPTION_KEY,
NOTION_OAUTH_CLIENT_ID,
@@ -10,17 +10,10 @@ import {
WEBAPP_URL,
} from "@/lib/constants";
import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
handler: async ({ req }: { req: NextRequest }) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
@@ -33,13 +26,6 @@ export const GET = withV1ApiWrapper({
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
if (code && typeof code !== "string") {
return {
response: responses.badRequestResponse("`code` must be a string"),

View File

@@ -5,19 +5,12 @@ import {
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
handler: async ({ req }: { req: NextRequest }) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
@@ -30,13 +23,6 @@ export const GET = withV1ApiWrapper({
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
if (code && typeof code !== "string") {
return {
response: responses.badRequestResponse("`code` must be a string"),

View File

@@ -8,7 +8,10 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
@@ -146,7 +149,6 @@ export const PUT = withV1ApiWrapper({
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
responseUpdate.finished,
result.survey.questions
);

View File

@@ -7,7 +7,10 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
@@ -155,7 +158,6 @@ export const POST = withV1ApiWrapper({
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
responseInput.finished,
surveyResult.survey.questions
);

View File

@@ -0,0 +1,6 @@
import {
OPTIONS,
PUT,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
export { OPTIONS, PUT };

View File

@@ -0,0 +1,6 @@
import {
GET,
OPTIONS,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
export { GET, OPTIONS };

View File

@@ -11,7 +11,6 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -107,23 +106,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);
if (validationErrors) {
return responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
);
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInputV2["meta"] = {

View File

@@ -3,9 +3,8 @@
// Error components must be Client components
import * as Sentry from "@sentry/nextjs";
import { TFunction } from "i18next";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { type ClientErrorType, getClientErrorData, isExpectedError } from "@formbricks/types/errors";
import { type ClientErrorType, getClientErrorData } from "@formbricks/types/errors";
import { Button } from "@/modules/ui/components/button";
import { ErrorComponent } from "@/modules/ui/components/error-component";
@@ -31,13 +30,11 @@ const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) =>
const errorData = getClientErrorData(error);
const { title, description } = getErrorMessages(errorData.type, t);
useEffect(() => {
if (process.env.NODE_ENV === "development") {
console.error(error.message);
} else if (!isExpectedError(error)) {
Sentry.captureException(error);
}
}, [error]);
if (process.env.NODE_ENV === "development") {
console.error(error.message);
} else {
Sentry.captureException(error);
}
return (
<div className="flex h-full w-full flex-col items-center justify-center">

View File

@@ -68,6 +68,7 @@ vi.mock("@/app/middleware/endpoint-validator", async () => {
isClientSideApiRoute: vi.fn().mockReturnValue({ isClientSideApi: false, isRateLimited: true }),
isManagementApiRoute: vi.fn().mockReturnValue({ isManagementApi: false, authenticationMethod: "apiKey" }),
isIntegrationRoute: vi.fn().mockReturnValue(false),
isSyncWithUserIdentificationEndpoint: vi.fn().mockReturnValue(null),
};
});
@@ -81,6 +82,7 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
syncUserIdentification: { windowMs: 60000, max: 50 },
},
},
}));
@@ -461,6 +463,45 @@ describe("withV1ApiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("handles sync user identification rate limiting", async () => {
const { applyRateLimit, applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
const {
isClientSideApiRoute,
isManagementApiRoute,
isIntegrationRoute,
isSyncWithUserIdentificationEndpoint,
} = await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(isSyncWithUserIdentificationEndpoint).mockReturnValue({
userId: "user-123",
environmentId: "env-123",
});
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
const rateLimitError = new Error("Sync rate limit exceeded");
rateLimitError.message = "Sync rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ url: "/api/v1/client/env-123/app/sync/user-123" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(applyRateLimit).toHaveBeenCalledWith(
expect.objectContaining({ windowMs: 60000, max: 50 }),
"user-123"
);
});
test("skips audit log creation when no action/targetType provided", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"

View File

@@ -10,6 +10,7 @@ import {
isClientSideApiRoute,
isIntegrationRoute,
isManagementApiRoute,
isSyncWithUserIdentificationEndpoint,
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -47,16 +48,23 @@ enum ApiV1RouteTypeEnum {
}
/**
* Apply client-side API rate limiting (IP-based)
* Apply client-side API rate limiting (IP-based or sync-specific)
*/
const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
const applyClientRateLimit = async (url: string, customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
const syncEndpoint = isSyncWithUserIdentificationEndpoint(url);
if (syncEndpoint) {
const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification;
await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId);
} else {
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
}
};
/**
* Handle rate limiting based on authentication and API type
*/
const handleRateLimiting = async (
url: string,
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum,
customRateLimitConfig?: TRateLimitConfig
@@ -76,7 +84,7 @@ const handleRateLimiting = async (
}
if (routeType === ApiV1RouteTypeEnum.Client) {
await applyClientRateLimit(customRateLimitConfig);
await applyClientRateLimit(url, customRateLimitConfig);
}
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
@@ -247,6 +255,7 @@ const getRouteType = (
* Features:
* - Performs authentication once and passes result to handler
* - Applies API key-based rate limiting with differentiated limits for client vs management APIs
* - Includes additional sync user identification rate limiting for client-side sync endpoints
* - Sets userId and organizationId in audit log automatically when audit logging is enabled
* - System and Sentry logs are always called for non-success responses
* - Uses function overloads to provide type safety without requiring type guards
@@ -319,7 +328,12 @@ export const withV1ApiWrapper: {
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
const rateLimitResponse = await handleRateLimiting(
req.nextUrl.pathname,
authentication,
routeType,
customRateLimitConfig
);
if (rateLimitResponse) return rateLimitResponse;
}

View File

@@ -4848,14 +4848,12 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
t("templates.preview_survey_question_2_choice_2_label"),
],
headline: t("templates.preview_survey_question_2_headline"),
subheader: t("templates.preview_survey_question_2_subheader"),
required: true,
shuffleOption: "none",
}),
isDraft: true,
},
],
buttonLabel: createI18nString(t("templates.next"), []),
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
},
{

View File

@@ -8,6 +8,7 @@ import {
isManagementApiRoute,
isPublicDomainRoute,
isRouteAllowedForDomain,
isSyncWithUserIdentificationEndpoint,
} from "./endpoint-validator";
describe("endpoint-validator", () => {
@@ -269,6 +270,58 @@ describe("endpoint-validator", () => {
});
});
describe("isSyncWithUserIdentificationEndpoint", () => {
test("should return environmentId and userId for valid sync URLs", () => {
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
expect(result1).toEqual({
environmentId: "env123",
userId: "user456",
});
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/abc-123/app/sync/xyz-789");
expect(result2).toEqual({
environmentId: "abc-123",
userId: "xyz-789",
});
const result3 = isSyncWithUserIdentificationEndpoint(
"/api/v1/client/env_123_test/app/sync/user_456_test"
);
expect(result3).toEqual({
environmentId: "env_123_test",
userId: "user_456_test",
});
});
test("should handle optional trailing slash", () => {
// Test both with and without trailing slash
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
expect(result1).toEqual({
environmentId: "env123",
userId: "user456",
});
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456/");
expect(result2).toEqual({
environmentId: "env123",
userId: "user456",
});
});
test("should return false for invalid sync URLs", () => {
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/other/user456")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v2/client/env123/app/sync/user456")).toBe(false); // only v1 supported
});
test("should handle empty or malformed IDs", () => {
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client//app/sync/user456")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/")).toBe(false);
});
});
describe("isPublicDomainRoute", () => {
test("should return true for health endpoint", () => {
expect(isPublicDomainRoute("/health")).toBe(true);
@@ -529,6 +582,12 @@ describe("endpoint-validator", () => {
test("should handle special characters in survey IDs", () => {
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
expect(
isSyncWithUserIdentificationEndpoint("/api/v1/client/env-123_test/app/sync/user-456_test")
).toEqual({
environmentId: "env-123_test",
userId: "user-456_test",
});
});
});
@@ -569,6 +628,15 @@ describe("endpoint-validator", () => {
const longSurveyId = "a".repeat(1000);
const longPath = `s/${longSurveyId}`;
expect(isPublicDomainRoute(`/${longPath}`)).toBe(true);
const longEnvironmentId = "env" + "a".repeat(1000);
const longUserId = "user" + "b".repeat(1000);
expect(
isSyncWithUserIdentificationEndpoint(`/api/v1/client/${longEnvironmentId}/app/sync/${longUserId}`)
).toEqual({
environmentId: longEnvironmentId,
userId: longUserId,
});
});
test("should handle empty and minimal inputs", () => {
@@ -583,6 +651,7 @@ describe("endpoint-validator", () => {
});
expect(isIntegrationRoute("")).toBe(false);
expect(isAuthProtectedRoute("")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("")).toBe(false);
});
});

View File

@@ -43,6 +43,14 @@ export const isAuthProtectedRoute = (url: string): boolean => {
return protectedRoutes.some((route) => url.startsWith(route));
};
export const isSyncWithUserIdentificationEndpoint = (
url: string
): { environmentId: string; userId: string } | false => {
const regex = /\/api\/v1\/client\/(?<environmentId>[^/]+)\/app\/sync\/(?<userId>[^/]+)/;
const match = url.match(regex);
return match ? { environmentId: match.groups!.environmentId, userId: match.groups!.userId } : false;
};
/**
* Check if the route should be accessible on the public domain (PUBLIC_URL)
* Uses whitelist approach - only explicitly allowed routes are accessible

View File

@@ -8,7 +8,7 @@ import { authorizePrivateDownload } from "@/app/storage/[environmentId]/[accessT
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { deleteFile, getFileStreamForDownload } from "@/modules/storage/service";
import { deleteFile, getSignedUrlForDownload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { logFileDeletion } from "./lib/audit-logs";
@@ -39,25 +39,21 @@ export const GET = async (
}
}
// Stream the file directly
const streamResult = await getFileStreamForDownload(fileName, environmentId, accessType);
const signedUrlResult = await getSignedUrlForDownload(fileName, environmentId, accessType);
if (!streamResult.ok) {
const errorResponse = getErrorResponseFromStorageError(streamResult.error, { fileName });
if (!signedUrlResult.ok) {
const errorResponse = getErrorResponseFromStorageError(signedUrlResult.error, { fileName });
return errorResponse;
}
const { body, contentType, contentLength } = streamResult.data;
return new Response(body, {
status: 200,
return new Response(null, {
status: 302,
headers: {
"Content-Type": contentType,
...(contentLength > 0 && { "Content-Length": String(contentLength) }),
Location: signedUrlResult.data,
"Cache-Control":
accessType === "private"
? "no-store, no-cache, must-revalidate"
: "public, max-age=31536000, immutable",
: "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
},
});
};

View File

@@ -114,7 +114,6 @@ 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
@@ -137,7 +136,7 @@ checksums:
common/code: 343bc5386149b97cece2b093c39034b2
common/collapse_rows: 24988527f9180f37aa55d2aa183ccb21
common/completed: 0e4bbce9985f25eb673d9a054c8d5334
common/configure: e3ab18ebb36c218cd4897c620f5809ac
common/configuration: 923ec0502721489202f6222dd4107163
common/confirm: 90930b51154032f119fa75c1bd422d8b
common/connect: 8778ee245078a8be4a2ce855c8c56edc
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
@@ -162,7 +161,6 @@ checksums:
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
common/date: 56f41c5d30a76295bb087b20b7bee4c3
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
common/delete: 8bcf303dd10a645b5baacb02b47d72c9
common/description: e17686a22ffad04cc7bb70524ed4478b
@@ -173,7 +171,6 @@ checksums:
common/disallow: 01c8ed3ce545ed836d3ccffc562c8a0c
common/discard: de83a114a79d086e372c43dbfe9f47b4
common/dismissed: f0e21b3fe28726c577a7238a63cc29c2
common/distribute: 0b702c85b5d4069d8367cb461c2ee0b1
common/docs: 1563fcb5ddb5037b0709ccd3dd384a92
common/documentation: 1563fcb5ddb5037b0709ccd3dd384a92
common/domain: 402d46965eacc3af4c5df92e53e95712
@@ -184,7 +181,6 @@ checksums:
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
common/enable: 463972a7a95f50f3105d09b92508f2cd
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
@@ -200,7 +196,6 @@ checksums:
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
common/failed_to_parse_csv: 7a3d675ecbb3d15884faf1006a5752d6
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
@@ -253,7 +248,6 @@ checksums:
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
common/mobile_overlay_title: 42f52259b7527989fb3a3240f5352a8b
common/months: da74749fbe80394fa0f72973d7b0964a
common/move_down: 4f4de55743043355ad4a839aff2c48ff
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
@@ -264,7 +258,6 @@ checksums:
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
common/no_overlay: 03cde9e91f08e4dd539d788e1e01407f
common/no_quotas_found: 19dea6bcc39b579351073b3974990cb6
common/no_result_found: fedddbc0149972ea072a9e063198a16d
common/no_results: 0e9b73265c6542240f5a3bf6b43e9280
@@ -291,7 +284,6 @@ checksums:
common/organization_teams_not_found: ce29fcb7a4e8b4582f92b65dea9b7d4e
common/other: 79acaa6cd481262bea4e743a422529d2
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
common/password: 223a61cf906ab9c40d22612c588dff48
common/paused: edb1f7b7219e1c9b7aa67159090d6991
@@ -372,7 +364,6 @@ checksums:
common/status: 4e1fcce15854d824919b4a582c697c90
common/step_by_step_manual: 2894a07952a4fd11d98d5d8f1088690c
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
common/string: 4ddccc1974775ed7357f9beaf9361cec
common/styling: 240fc91eb03c52d46b137f82e7aec2a1
common/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e
common/summary: 13eb7b8a239fb4702dfdaee69100a220
@@ -405,7 +396,6 @@ 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
@@ -429,7 +419,6 @@ checksums:
common/website_and_app_connection: 60fea5cff5bddb4db3c8a1b0a2f9ec63
common/website_app_survey: 258579927ed3955dcc8e1cbd7f0df17f
common/website_survey: 17513d25a07b6361768a15ec622b021b
common/weeks: 545de30df4f44d3f6d1d344af6a10815
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
@@ -440,7 +429,6 @@ checksums:
common/workspace_not_found: 038fb0aaf3570610f4377b9eaed13752
common/workspace_permission_not_found: e94bdff8af51175c5767714f82bb4833
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
@@ -603,12 +591,6 @@ checksums:
environments/contacts/attribute_updated_successfully: 0e64422156c29940cd4dab2f9d1f40b2
environments/contacts/attribute_value: 34b0eaa85808b15cbc4be94c64d0146b
environments/contacts/attribute_value_placeholder: 90fb17015de807031304d7a650a6cb8c
environments/contacts/attributes_msg_attribute_limit_exceeded: a6c430860f307f9cc90c449f96a1284f
environments/contacts/attributes_msg_attribute_type_validation_error: ed177ce83bd174ed6be7e889664f93a1
environments/contacts/attributes_msg_email_already_exists: a3ea1265e3db885f53d0e589aecf6260
environments/contacts/attributes_msg_email_or_userid_required: 3be0e745cd3500c9a23bad2e25ad3147
environments/contacts/attributes_msg_new_attribute_created: c4c7b27523058f43b70411d7aa6510e5
environments/contacts/attributes_msg_userid_already_exists: d2d95ece4b06507be18c9ba240b0a26b
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
@@ -617,11 +599,6 @@ checksums:
environments/contacts/create_key: 0d385c354af8963acbe35cd646710f86
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
environments/contacts/custom_attributes: fffc7722742d1291b102dc737cf2fc9e
environments/contacts/data_type: 1ea127ba2c18d0d91fb0361cc6747e2b
environments/contacts/data_type_cannot_be_changed: 22603f6193fdac3784eeef8315df70de
environments/contacts/data_type_description: 800bf4935df15e6cb14269e1b60c506e
environments/contacts/date_value_required: e0e75b75ae4e8c02f03284954756adc9
environments/contacts/delete_attribute_confirmation: 01d99b89eb3d27ff468d0db1b4aeb394
environments/contacts/delete_contact_confirmation: 2d45579e0bb4bc40fb1ee75b43c0e7a4
environments/contacts/delete_contact_confirmation_with_quotas: d3d17f13ae46ce04c126c82bf01299ac
@@ -629,18 +606,13 @@ checksums:
environments/contacts/edit_attribute_description: 073a3084bb2f3b34ed1320ed1cd6db3c
environments/contacts/edit_attribute_values: 44e4e7a661cc1b59200bb07c710072a7
environments/contacts/edit_attribute_values_description: 21593dfaf4cad965ffc17685bc005509
environments/contacts/edit_attributes: a5c3b540441d34b4c0b7faab8f0f0c89
environments/contacts/edit_attributes_success: 39f93b1a6f1605bc5951f4da5847bb22
environments/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
environments/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
environments/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
environments/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
environments/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
environments/contacts/not_provided: a09e4d61bbeb04b927406a50116445e2
environments/contacts/number_value_required: d82a198a378eb120f3329e4d3fd4d3f7
environments/contacts/personal_link_generated: efb7a0420bd459847eb57bca41a4ab0d
environments/contacts/personal_link_generated_but_clipboard_failed: 4eb1e208e729bd5ac00c33f72fc38d53
environments/contacts/personal_survey_link: 5b3f1afc53733718c4ed5b1443b6a604
@@ -649,22 +621,13 @@ checksums:
environments/contacts/search_contact: 020205a93846ab3e12c203ac4fa97c12
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
environments/contacts/select_attribute_key: 673a6683fab41b387d921841cded7e38
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
environments/contacts/upload_contacts_error_attribute_type_mismatch: 70a60f0886ce767c00defa7d4aad0f93
environments/contacts/upload_contacts_error_duplicate_mappings: 9c1e1f07e476226bad98ccfa07979fec
environments/contacts/upload_contacts_error_file_too_large: 0c1837286c55d18049277465bc2444c1
environments/contacts/upload_contacts_error_generic: 3a8d35a421b377198361af9972392693
environments/contacts/upload_contacts_error_invalid_file_type: 15ef4fa7c2d5273b05a042f398655e81
environments/contacts/upload_contacts_error_no_valid_contacts: 27fbd24ed2d2fa3b6ed7b3a8c1dad343
environments/contacts/upload_contacts_modal_attribute_header: 263246ad2a76f8e2f80f0ed175d7629a
environments/contacts/upload_contacts_modal_attributes_description: e2cedbd4a043423002cbb2048e2145ac
environments/contacts/upload_contacts_modal_attributes_new: 9829382598c681de74130440a37b560f
environments/contacts/upload_contacts_modal_attributes_search_or_add: 1874839e465650d353282b43b00247a9
environments/contacts/upload_contacts_modal_attributes_should_be_mapped_to: 693dfe5836e90b1c4c7c65b015418174
environments/contacts/upload_contacts_modal_attributes_title: 86d0ae6fea0fbb119722ed3841f8385a
environments/contacts/upload_contacts_modal_csv_column_header: f181add48fb8325efaa40579fe8c343e
environments/contacts/upload_contacts_modal_description: 41566d40d25cc882aa9f82d87b4e2f03
environments/contacts/upload_contacts_modal_download_example_csv: 7a186fc4941b264452ee6c9e785769de
environments/contacts/upload_contacts_modal_duplicates_description: 112ce4641088520469a83a0bd740b073
@@ -831,40 +794,6 @@ checksums:
environments/segments/no_attributes_yet: 57beecc917dcd598ccdd0ccfb364a960
environments/segments/no_filters_yet: d885a68516840e15dd27f1c17d9a8975
environments/segments/no_segments_yet: 6307a4163a5bd553bb2aba074d24be9c
environments/segments/operator_contains: 06dd606c0a8f81f9a03b414e9ae89440
environments/segments/operator_does_not_contain: 854da2bdf10613ce62fb454bab16c58b
environments/segments/operator_ends_with: 2bd866369766c6a2ef74bb9fa74b1d7e
environments/segments/operator_is_after: f9d9296eb9a5a7d168cc4e65a4095a87
environments/segments/operator_is_before: 2462480cf4e8d2832b64004fbd463e55
environments/segments/operator_is_between: 41ff45044d8a017a8a74f72be57916b8
environments/segments/operator_is_newer_than: c41e03366623caed6b2c224e50387614
environments/segments/operator_is_not_set: 906801489132487ef457652af4835142
environments/segments/operator_is_older_than: acca6b309da507bbc5973c4b56b698b0
environments/segments/operator_is_same_day: c06506b6eb9f6491f15685baccd68897
environments/segments/operator_is_set: 9850468156356f95884bbaf56b6687aa
environments/segments/operator_starts_with: 37e55e9080c84a1855956161e7885c21
environments/segments/operator_title_contains: 41c8c25407527a5336404313f4c8d650
environments/segments/operator_title_does_not_contain: d618eb0f854f7efa0d7c644e6628fa42
environments/segments/operator_title_ends_with: c8a5f60f1bd1d8fa018dbbf49806fb5b
environments/segments/operator_title_equals: 73439e2839b8049e68079b1b6f2e3c41
environments/segments/operator_title_greater_equal: 556b342cee0ac7055171e41be80f49e4
environments/segments/operator_title_greater_than: e06dabbbf3a9c527502c997101edab40
environments/segments/operator_title_is_after: bd4cf644e442fca330cb483528485e5f
environments/segments/operator_title_is_before: a47ce3825c5c7cea7ed7eb8d5505a2d5
environments/segments/operator_title_is_between: 5721c877c60f0005dc4ce78d4c0d3fdc
environments/segments/operator_title_is_newer_than: 133731671413c702a55cdfb9134d63f8
environments/segments/operator_title_is_not_set: c1a6fd89387686d3a5426a768bb286e9
environments/segments/operator_title_is_older_than: 9064cd482f2312c8b10aee4937d0278d
environments/segments/operator_title_is_same_day: 9340bf7bd6ab504d71b0e957ca9fcf4c
environments/segments/operator_title_is_set: 1c66019bd162201db83aef305ab2a161
environments/segments/operator_title_less_equal: 235dbef60cd0af5ff1d319aab24a1109
environments/segments/operator_title_less_than: e9f3c9742143760b28bf4e326f63a97b
environments/segments/operator_title_not_equals: a186482f46739c9fe8683826a1cab723
environments/segments/operator_title_starts_with: f6673c17475708313c6a0f245b561781
environments/segments/operator_title_user_is_in: 33ecd1bc30f56d97133368f1b244ee4b
environments/segments/operator_title_user_is_not_in: 99d576a3611d171947fd88c317aaf5f3
environments/segments/operator_user_is_in: 33ecd1bc30f56d97133368f1b244ee4b
environments/segments/operator_user_is_not_in: 99d576a3611d171947fd88c317aaf5f3
environments/segments/person_and_attributes: 507023d577326a6326dd9603dcdc589d
environments/segments/phone: b9537ee90fc5b0116942e0af29d926cc
environments/segments/please_remove_the_segment_from_these_surveys_in_order_to_delete_it: 1858a8ae40bed3a8c06c3bb518e0b8aa
@@ -889,7 +818,6 @@ checksums:
environments/segments/user_targeting_is_currently_only_available_when: 9785f159fb045607b62461f38e8d3aee
environments/segments/value_cannot_be_empty: 99efd449ec19f1ecc5cf0b6807d4f315
environments/segments/value_must_be_a_number: 87516b5c69e08741fa8a6ddf64d60deb
environments/segments/value_must_be_positive: d17ad009f7845a6fbeddeb2aef532e10
environments/segments/view_filters: 791cd4bacb11e3eb0ffccee131270561
environments/segments/where: 23aecda7d27f26121b057ec7f7327069
environments/segments/with_the_formbricks_sdk: 2b185e6242edb69e1bc6e64e10dfc02a
@@ -967,25 +895,11 @@ checksums:
environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12
environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44
environments/settings/enterprise/keep_full_control_over_your_data_privacy_and_security: 43aa041cc3e2b2fdd35d2d34659a6b7a
environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526
environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8
environments/settings/enterprise/license_status_active: 3e1ec025c4a50830bbb9ad57a176630a
environments/settings/enterprise/license_status_description: 828e4527f606471cd8cf58b55ff824f6
environments/settings/enterprise/license_status_expired: 63b27cccba4ab2143e0f5f3d46e4168a
environments/settings/enterprise/license_status_invalid: a4bfd3787fc0bf0a38db61745bd25cec
environments/settings/enterprise/license_status_unreachable: 202b110dab099f1167b13c326349e570
environments/settings/enterprise/license_unreachable_grace_period: c0587c9d79ac55ff2035fb8b8eec4433
environments/settings/enterprise/no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form: daef55124d4363526008eb91a0b68246
environments/settings/enterprise/no_credit_card_no_sales_call_just_test_it: 18f9859cdf12537b7019ecdb0a0a2b53
environments/settings/enterprise/on_request: cf9949748c15313a8fd57bf965bec16b
environments/settings/enterprise/organization_roles: 731d5028521c2a3a7bdbd7ed215dd861
environments/settings/enterprise/questions_please_reach_out_to: ac4be65ffef9349eaeb137c254d3fee7
environments/settings/enterprise/recheck_license: b913b64f89df184b5059710f4a0b26fa
environments/settings/enterprise/recheck_license_failed: dd410acbb8887625cf194189f832dd7c
environments/settings/enterprise/recheck_license_invalid: 58f41bc208692b7d53b975dfcf9f4ad8
environments/settings/enterprise/recheck_license_success: 700ddd805be904a415f614de3df1da78
environments/settings/enterprise/recheck_license_unreachable: 0ca81bd89595a9da24bc94dcef132175
environments/settings/enterprise/rechecking: 54c454aa8e4d27363543349b7c2a28bc
environments/settings/enterprise/request_30_day_trial_license: 8d5a1b5d9f0790783693122ac30c16ef
environments/settings/enterprise/saml_sso: 86b76024524fc585b2c3950126ef6f62
environments/settings/enterprise/service_level_agreement: e31e74f66f5c7c79e82878f4f68abc37
@@ -993,6 +907,7 @@ checksums:
environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112
environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea
environments/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: 104d07b63a42911c9673ceb08a4dbd43
environments/settings/enterprise/your_enterprise_license_is_active_all_features_unlocked: f03f3c7a81f61eb5cd78fa7ad32896f8
environments/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37
environments/settings/general/cannot_delete_only_organization: 833cc6848b28f2694a4552b4de91a6ba
environments/settings/general/cannot_leave_only_organization: dd8463262e4299fef7ad73512225c55b
@@ -1121,6 +1036,8 @@ checksums:
environments/settings/teams/please_fill_all_workspace_fields: 190fc5d3c63cc5ec49d77f587e619ed8
environments/settings/teams/read: 2494ca23d10e5b6381eb271aceeb5270
environments/settings/teams/read_write: 278a90dade128198d4c93ac00c345320
environments/settings/teams/select_member: 7f4a38312aabbbe3fe92756b57bd5d75
environments/settings/teams/select_workspace: 0ad989c23616c6a04faf23d9e63ed3f3
environments/settings/teams/team_admin: 5df68214685738029af678ae1d5912bb
environments/settings/teams/team_created_successfully: 5b0cc007e18053508fdebc9545cc2c05
environments/settings/teams/team_deleted_successfully: d0729ad8d982cc5d542f89291bf57c50
@@ -1166,6 +1083,7 @@ checksums:
environments/surveys/edit/add_fallback_placeholder: 0e77ea487ddd7bc7fc2f1574b018dc08
environments/surveys/edit/add_hidden_field_id: a8f55b51b790cf5f4d898af7770ad1ed
environments/surveys/edit/add_highlight_border: 66f52b21fbb9aa6561c98a090abaaf8f
environments/surveys/edit/add_highlight_border_description: 1c04654a393c0fa31d2b58abb6f85b4b
environments/surveys/edit/add_logic: f234c9f1393a9ed4792dfbd15838c951
environments/surveys/edit/add_none_of_the_above: dbe1ada4512d6c3f80c54c8fac107ec6
environments/surveys/edit/add_option: 143c54f0b201067fe5159284d6daeca2
@@ -1204,7 +1122,6 @@ checksums:
environments/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376
environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6
environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e
environments/surveys/edit/brand_color_description: 1cd10092621d375a37e297cc6353bce7
environments/surveys/edit/brightness: 45425b6db1872225bfff71cf619d0e64
environments/surveys/edit/bulk_edit: 59bd1a55587c8cbad716afbf2509e5bb
environments/surveys/edit/bulk_edit_description: 9b5b2c6183c6c51689e16d7ba02ec9bb
@@ -1222,9 +1139,7 @@ checksums:
environments/surveys/edit/capture_new_action: 0aa2a3c399b62b1a52307deedf4922e8
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
environments/surveys/edit/card_background_color_description: c96baa7fab5f2dfc41ff2e6a4e0242b0
environments/surveys/edit/card_border_color: 8d7c7f4cbd99f154ce892dfa258eb504
environments/surveys/edit/card_border_color_description: 57828ef76f8d055c530c1e0b0c0ddc09
environments/surveys/edit/card_styling: 47137a7e809b060ca94418202a8fd3c5
environments/surveys/edit/casual: 6534fe68718fade470a9031f7390409e
environments/surveys/edit/caution_edit_duplicate: ee93bccb34fcd707e1ef4735f1c2fc31
@@ -1235,12 +1150,20 @@ checksums:
environments/surveys/edit/caution_explanation_responses_are_safe: 090ff00b7922a49c273e67c5f364730d
environments/surveys/edit/caution_recommendation: b15090fe878ff17f2ee7cc2082dd9018
environments/surveys/edit/caution_text: 3291e962c0e4c4656832837ddc512918
environments/surveys/edit/centered_modal_overlay_color: 1124ba61ee2ecb18a7175ff780dc3b60
environments/surveys/edit/change_anyway: 6377497d40373f6d0f082670194981ab
environments/surveys/edit/change_background: fa71a993869f7d3ac553c547c12c3e9b
environments/surveys/edit/change_question_type: 2d555ae48df8dbedfc6a4e1ad492f4aa
environments/surveys/edit/change_survey_type: c26322043a476da6d94adb8b4efe1e93
environments/surveys/edit/change_the_background_color_of_the_card: 41d805ef753a7d1e272b48519967bbd4
environments/surveys/edit/change_the_background_color_of_the_input_fields: 4edbc9a9f5d145ed096cf5b4f8bdaac0
environments/surveys/edit/change_the_background_to_a_color_image_or_animation: f1b9c9eb61497dd91b2550dd50c77836
environments/surveys/edit/change_the_border_color_of_the_card: 64d76b247ab192343bb327f92a5f220c
environments/surveys/edit/change_the_border_color_of_the_input_fields: bb687f41af15a1dd9494c14f97b10425
environments/surveys/edit/change_the_border_radius_of_the_card_and_the_inputs: 9eccf688a7a67dfeeeed3de5209058b0
environments/surveys/edit/change_the_brand_color_of_the_survey: ecc420c641fb58daaf4d2d0086357b7f
environments/surveys/edit/change_the_placement_of_this_survey: 64359611bfb23bacc614ffe0b08fbe5d
environments/surveys/edit/change_the_question_color_of_the_survey: ab6942138a8c5fc6c8c3b9f8dd95e980
environments/surveys/edit/changes_saved: 90aab363c9e96eaa1295a997c48f97f6
environments/surveys/edit/changing_survey_type_will_remove_existing_distribution_channels: 9ce817be04f13f2f0db981145ec48df4
environments/surveys/edit/checkbox_label: 12a07d6bdf38e283a2e95892ec49b7f8
@@ -1380,6 +1303,7 @@ checksums:
environments/surveys/edit/hide_progress_bar: 7eefe7db6a051105bded521d94204933
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
environments/surveys/edit/how_funky_do_you_want_your_cards_in_survey_type_derived_surveys: 3cb16b37510c01af20a80f51b598346e
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
environments/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489
@@ -1390,9 +1314,7 @@ checksums:
environments/surveys/edit/initial_value: 809ee46fd787f4dc0146b3a80af5c2de
environments/surveys/edit/inner_text: d1c7c98cfdb2fae3be91b7ee44288847
environments/surveys/edit/input_border_color: d8a68d6b573189c291db6d83496210f6
environments/surveys/edit/input_border_color_description: d338a4a6556db30ae7d5f8c7027bdcd4
environments/surveys/edit/input_color: 55a0a092d16a1a6899c07b1b00d08604
environments/surveys/edit/input_color_description: fa9f72ea65480c6b6e9e14b89109af03
environments/surveys/edit/insert_link: c42ce4cb6ed35d5bd1389523585cc57e
environments/surveys/edit/invalid_targeting: db9d1143c82a085c5dddf09492ea753c
environments/surveys/edit/invalid_video_url_warning: 2e6a8eb121b46d7c3cc79d541b6a3cd5
@@ -1476,6 +1398,7 @@ checksums:
environments/surveys/edit/protect_survey_with_pin_description: 0e55d19b6f3578b1024e03606172a5d2
environments/surveys/edit/publish: 4aa95ba4793bb293e771bd73b4f87c0f
environments/surveys/edit/question: 0576462ce60d4263d7c482463fcc9547
environments/surveys/edit/question_color: 6e69cb5699368bc68b2e1e1501f555c9
environments/surveys/edit/question_deleted: ecdeb22b81ae2d732656a7742c1eec7b
environments/surveys/edit/question_duplicated: 3f02439fd0a8b818bc84c1b1b473898c
environments/surveys/edit/question_id_updated: e8d94dbefcbad00c7464b3d1fb0ee81a
@@ -1535,7 +1458,6 @@ checksums:
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
environments/surveys/edit/roundness_description: bde131aa5674836416dcdf2ff517d899
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
environments/surveys/edit/rows: 8f41f34e6ca28221cf1ebd948af4c151
environments/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
@@ -1577,6 +1499,7 @@ checksums:
environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69
environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0
environments/surveys/edit/subtract: 2d83b8b9ef35110f2583ddc155b6c486
environments/surveys/edit/suggest_colors: ddc4543b416ab774007b10a3434343cd
environments/surveys/edit/survey_completed_heading: dae5ac4a02a886dc9d9fc40927091919
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
@@ -1933,95 +1856,6 @@ 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/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
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_duplicated_successfully: eb21ce42cdbef5fa38244206bf65fe4e
environments/unify/connector_status_updated_successfully: 443fd63b27f15a81ff146375adac739f
environments/unify/connector_updated_successfully: 11308c4a2881345209cefa06a3d90eab
environments/unify/create_mapping: cbe8c951e7819f574ca7d793920b2b60
environments/unify/created_by: 6775c2fa7d495fea48f1ad816daea93b
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_import_complete: e8b6306e62e10c128f6464176ba879dd
environments/unify/csv_import_duplicate_warning: 3bedb07a01939d6b4ad93f68b7adf0e5
environments/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6
environments/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
environments/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
environments/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
environments/unify/deselect_all: facf8871b2e84a454c6bfe40c2821922
environments/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
environments/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
environments/unify/edit_source_connection: eb42476becc8de3de4ca9626828573f0
environments/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
environments/unify/enter_value: 4f068bb59617975c1e546218373122cd
environments/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
environments/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
environments/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
environments/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a
environments/unify/historical_import_complete: f46f98bf4db63bf2993bfb234dc95f62
environments/unify/import_csv_data: e5f873b0e6116c5144677acf38607f2e
environments/unify/import_rows: d2963498a7d2766264c4d67db677e8ff
environments/unify/importing_data: a6d4478379a0faee05cd2c10ffe74984
environments/unify/importing_historical_data: f5be578704ec26dc4ec573309e9fff20
environments/unify/invalid_enum_values: e6ca8740dab72f64e8dc5780b5cffcc6
environments/unify/invalid_values_found: 5011dc9c0294a222033f9910ea919b8a
environments/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e
environments/unify/n_supported_questions: d75413d386441b5eb137a1ea191e4bd9
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_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
environments/unify/question_selected: b9ff13b6212874258da911867932dc7d
environments/unify/question_type_not_supported: 8d9f7554e3b509dfd5307d8d1fef08d7
environments/unify/questions_selected: 1f13d6fecafa2ce5ea9e6d07078a1d38
environments/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
environments/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
environments/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20
environments/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862
environments/unify/select_all: eedc7cdb02de467c15dc418a066a77f2
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: 45309626f464f4bda161ee783a4c8c80
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: 3de9afebcb9d4ce8ac42e14995f79ffd
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_questions: c08514b6bce5eb464a4492239be5934d
environments/unify/survey_import_line: 63fa0ea1d7daa3ba333436fbc65f8b19
environments/unify/total_feedback_records: 8962087650b62e4a12b81e7d09317ffa
environments/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
environments/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
environments/unify/updated_at: 8fdb85248e591254973403755dcc3724
environments/unify/upload_csv_data_description: 7fab46222ab05a4424db90a7cc96cdf5
environments/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
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
@@ -2105,71 +1939,9 @@ checksums:
environments/workspace/languages/translate: 59f9803b27e2030ba7323ed239116cf7
environments/workspace/look/add_background_color: 9be512ee1246e32d3958c56097d202d9
environments/workspace/look/add_background_color_description: adb6fcb392862b3d0e9420d9b5405ddb
environments/workspace/look/advanced_styling_field_border_radius: 63b8f3541a9792d705e67d5aca7b6451
environments/workspace/look/advanced_styling_field_button_bg: fc103ab926721e6213d39cc1f913c018
environments/workspace/look/advanced_styling_field_button_bg_description: 9f14ec79ed40c0d3eb168cc46a9e0a14
environments/workspace/look/advanced_styling_field_button_border_radius_description: 5677ee84511896ab9c369c0aced4c352
environments/workspace/look/advanced_styling_field_button_font_size_description: 59508854b0101a89fab8250f79c0f3ba
environments/workspace/look/advanced_styling_field_button_font_weight_description: d3dab571b0f1bc09d645be66c6686a06
environments/workspace/look/advanced_styling_field_button_height_description: 1ab13a11281d2c303146e0483f12d948
environments/workspace/look/advanced_styling_field_button_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
environments/workspace/look/advanced_styling_field_button_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
environments/workspace/look/advanced_styling_field_button_text: 3304e88bcc3869f3a306634b541e1e07
environments/workspace/look/advanced_styling_field_button_text_description: 088f12906c8d2c06d3506f51d8ef8a89
environments/workspace/look/advanced_styling_field_description_color: e2f4cbc96d3f0b75837a9edc95a5eeda
environments/workspace/look/advanced_styling_field_description_color_description: f69d10a21c9233e0803f024f2e555485
environments/workspace/look/advanced_styling_field_description_size: a0d51c3ab7dc56320ecedc2b27917842
environments/workspace/look/advanced_styling_field_description_size_description: ff880ea1beddd1b1ec7416d0b8a69cf3
environments/workspace/look/advanced_styling_field_description_weight: 514680cc7202ad29835c1cbcde3def1c
environments/workspace/look/advanced_styling_field_description_weight_description: 441ac8db1a32557813eb68fbfd759061
environments/workspace/look/advanced_styling_field_font_size: ca44d14429b2175a1b194793b4ab8f6b
environments/workspace/look/advanced_styling_field_font_weight: bfef83778146cf40550df9650d8a07da
environments/workspace/look/advanced_styling_field_headline_color: 4ccf3935ad90c88ad4add24f498673ce
environments/workspace/look/advanced_styling_field_headline_color_description: b3fa9c2fc5da9ee11c1f279e4f949600
environments/workspace/look/advanced_styling_field_headline_size: ddc49fa27fc97ed286d5c4309edd9a3c
environments/workspace/look/advanced_styling_field_headline_size_description: 13debc3855e4edae992c7a1ebff599c3
environments/workspace/look/advanced_styling_field_headline_weight: 0c8b8262945c61f8e2978502362e0a42
environments/workspace/look/advanced_styling_field_headline_weight_description: 1a9c40bd76ff5098b1e48b1d3893171b
environments/workspace/look/advanced_styling_field_height: 40ca2224bb2936ad1329091b35a9ffe2
environments/workspace/look/advanced_styling_field_indicator_bg: 00febda2901af0f1b0c17e44f9917c38
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
environments/workspace/look/advanced_styling_field_input_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
environments/workspace/look/advanced_styling_field_input_height_description: e19ec0dc432478def0fd1199ad765e38
environments/workspace/look/advanced_styling_field_input_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
environments/workspace/look/advanced_styling_field_input_shadow_description: b59ea4007756cecda47f216987ba05f6
environments/workspace/look/advanced_styling_field_input_text: 4999bfded16b7d0bbcc858b399745eaa
environments/workspace/look/advanced_styling_field_input_text_description: 460450df24ea0cc902710118a5000feb
environments/workspace/look/advanced_styling_field_option_bg: 0ceaed10d99ed4ad83cb0934ab970174
environments/workspace/look/advanced_styling_field_option_bg_description: 6cd6ccecbbb9f2f19439d7c682eb67c1
environments/workspace/look/advanced_styling_field_option_border_radius_description: 23f81c25b2681a7c9e2c4f2e7d2e0656
environments/workspace/look/advanced_styling_field_option_font_size_description: 5430fd9b08819972f0a613bf3fa659da
environments/workspace/look/advanced_styling_field_option_label: 2767a5db32742073a01aac16488e93dc
environments/workspace/look/advanced_styling_field_option_label_description: f42c9fc7b19cc2cb9b366a4cd31ae695
environments/workspace/look/advanced_styling_field_option_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
environments/workspace/look/advanced_styling_field_option_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
environments/workspace/look/advanced_styling_field_padding_x: 74b440237b4ba662c9898d92e2e06217
environments/workspace/look/advanced_styling_field_padding_y: 441d777bdc1cd1e792bf9815cc937c6a
environments/workspace/look/advanced_styling_field_placeholder_opacity: fddcbc6e4fc5757aab807a6282d26627
environments/workspace/look/advanced_styling_field_shadow: 7b4af1b447ece2b19b5d7717b2e15c4e
environments/workspace/look/advanced_styling_field_track_bg: e569155b24616ba6d0a89a07bc85955c
environments/workspace/look/advanced_styling_field_track_bg_description: 8a56258273dfe49e83fe752ea9e8daed
environments/workspace/look/advanced_styling_field_track_height: 9ce57cb4583039c224a37e013efb6b8f
environments/workspace/look/advanced_styling_field_track_height_description: 90243a4374e15d9118ad0fd93d5f3614
environments/workspace/look/advanced_styling_field_upper_label_color: 65d75c60dfdba88e5fed38bcb24a0a5d
environments/workspace/look/advanced_styling_field_upper_label_color_description: ae2276506807c7ceeb7a8b87723a8dd4
environments/workspace/look/advanced_styling_field_upper_label_size: ea0ca9a3ffa1650f97a31df453b0afc7
environments/workspace/look/advanced_styling_field_upper_label_size_description: 061668625be0f7a68ebc2e2ebe49e5a9
environments/workspace/look/advanced_styling_field_upper_label_weight: 946c5836d2cfaaee21e494424d550887
environments/workspace/look/advanced_styling_field_upper_label_weight_description: 916b03c719a8dead351679336aabcf53
environments/workspace/look/advanced_styling_section_buttons: 3b44d6e2800e7bf3f133f1bce435f4c2
environments/workspace/look/advanced_styling_section_headlines: 6def704c0ac2ecb5951400c806856a41
environments/workspace/look/advanced_styling_section_inputs: 76bbeb561122a72fd3ec8c49eff7c563
environments/workspace/look/advanced_styling_section_options: a92819a15bc8c3eb44bdd82a5075c9e2
environments/workspace/look/app_survey_placement: f09cddac6bbb77d4694df223c6edf6b6
environments/workspace/look/app_survey_placement_settings_description: d81bcff7a866a2f83ff76936dbad4770
environments/workspace/look/centered_modal_overlay_color: 1124ba61ee2ecb18a7175ff780dc3b60
environments/workspace/look/email_customization: ae399f381183a4fe0ffd41ab496b5d8f
environments/workspace/look/email_customization_description: 5ccaf1769b2c39d7e87f3a08d056a374
environments/workspace/look/enable_custom_styling: 4774d8fb009c27044aa0191ebcccdcc2
@@ -2180,9 +1952,6 @@ checksums:
environments/workspace/look/formbricks_branding_hidden: fda9ba81f8d7fdaacf8dc1642034e145
environments/workspace/look/formbricks_branding_settings_description: 5bb39206c6412c703895593f465a01f9
environments/workspace/look/formbricks_branding_shown: 6c9861cf8f95e8a68c5c64b2630d96cd
environments/workspace/look/generate_theme_btn: 0345bf322c191e70d01fd6607ec5c2f8
environments/workspace/look/generate_theme_confirmation: f119dbb85fb2bda1c0bcdc581724ef3b
environments/workspace/look/generate_theme_header: 4df5f30a20cf78e248465915f222fd1b
environments/workspace/look/logo_removed_successfully: f3a7f9d226affa91121e90ff360553aa
environments/workspace/look/logo_settings_description: da155953f55cb44d0e563d9e740241aa
environments/workspace/look/logo_updated_successfully: 170250f18062b79be6ac0481ec9d4368
@@ -2197,8 +1966,6 @@ checksums:
environments/workspace/look/show_formbricks_branding_in: 80fabfec9b34a13c0445d02b923216ed
environments/workspace/look/show_powered_by_formbricks: a0e96edadec8ef326423feccc9d06be7
environments/workspace/look/styling_updated_successfully: b8b74b50dde95abcd498633e9d0c891f
environments/workspace/look/suggest_colors: ddc4543b416ab774007b10a3434343cd
environments/workspace/look/suggested_colors_applied_please_save: 226fa70af5efc8ffa0a3755909c8163e
environments/workspace/look/theme: 21fe00b7a518089576fb83c08631107a
environments/workspace/look/theme_settings_description: 9fc45322818c3774ab4a44ea14d7836e
environments/workspace/tags/add: 87c4a663507f2bcbbf79934af8164e13
@@ -2924,7 +2691,6 @@ checksums:
templates/preview_survey_question_2_choice_1_label: 7885d14d0e01962fd290395ccd96ecfc
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
templates/prioritize_features_description: 1eae41fad0e3947f803d8539081e59ec
templates/prioritize_features_name: 4ca59ff1f9c319aaa68c3106d820fd6a

View File

@@ -1,205 +1,59 @@
// OpenTelemetry instrumentation for Next.js - loaded via instrumentation.ts hook
// Pattern based on: ee/src/opentelemetry.ts (license server)
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
// instrumentation-node.ts
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { HostMetrics } from "@opentelemetry/host-metrics";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";
import {
AlwaysOffSampler,
AlwaysOnSampler,
BatchSpanProcessor,
ParentBasedSampler,
type Sampler,
TraceIdRatioBasedSampler,
} from "@opentelemetry/sdk-trace-base";
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
import { PrismaInstrumentation } from "@prisma/instrumentation";
detectResources,
envDetector,
hostDetector,
processDetector,
resourceFromAttributes,
} from "@opentelemetry/resources";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { logger } from "@formbricks/logger";
import { env } from "@/lib/env";
// --- Configuration from environment ---
const serviceName = process.env.OTEL_SERVICE_NAME || "formbricks";
const serviceVersion = process.env.npm_package_version || "0.0.0";
const environment = process.env.ENVIRONMENT || process.env.NODE_ENV || "development";
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
const prometheusEnabled = process.env.PROMETHEUS_ENABLED === "1";
const prometheusPort = process.env.PROMETHEUS_EXPORTER_PORT
? Number.parseInt(process.env.PROMETHEUS_EXPORTER_PORT)
: 9464;
// --- Configure OTLP exporters (conditional on endpoint being set) ---
let traceExporter: OTLPTraceExporter | undefined;
let otlpMetricExporter: OTLPMetricExporter | undefined;
if (otlpEndpoint) {
try {
// OTLPTraceExporter reads OTEL_EXPORTER_OTLP_ENDPOINT from env
// and appends /v1/traces for HTTP transport
// Uses OTEL_EXPORTER_OTLP_HEADERS from env natively (W3C OTel format: key=value,key2=value2)
traceExporter = new OTLPTraceExporter();
// OTLPMetricExporter reads OTEL_EXPORTER_OTLP_ENDPOINT from env
// and appends /v1/metrics for HTTP transport
// Uses OTEL_EXPORTER_OTLP_HEADERS from env natively
otlpMetricExporter = new OTLPMetricExporter();
} catch (error) {
logger.error(error, "Failed to create OTLP exporters. Telemetry will not be exported.");
}
}
// --- Configure Prometheus exporter (pull-based metrics for ServiceMonitor) ---
let prometheusExporter: PrometheusExporter | undefined;
if (prometheusEnabled) {
prometheusExporter = new PrometheusExporter({
port: prometheusPort,
endpoint: "/metrics",
host: "0.0.0.0",
});
}
// --- Build metric readers array ---
const metricReaders: (PeriodicExportingMetricReader | PrometheusExporter)[] = [];
if (otlpMetricExporter) {
metricReaders.push(
new PeriodicExportingMetricReader({
exporter: otlpMetricExporter,
exportIntervalMillis: 60000, // Export every 60 seconds
})
);
}
if (prometheusExporter) {
metricReaders.push(prometheusExporter);
}
// --- Resource attributes ---
const resourceAttributes: Record<string, string> = {
[ATTR_SERVICE_NAME]: serviceName,
[ATTR_SERVICE_VERSION]: serviceVersion,
"deployment.environment": environment,
};
// --- Configure sampler ---
const samplerType = process.env.OTEL_TRACES_SAMPLER || "always_on";
const parsedSamplerArg = process.env.OTEL_TRACES_SAMPLER_ARG
? Number.parseFloat(process.env.OTEL_TRACES_SAMPLER_ARG)
: undefined;
const samplerArg =
parsedSamplerArg !== undefined && !Number.isNaN(parsedSamplerArg) ? parsedSamplerArg : undefined;
let sampler: Sampler;
switch (samplerType) {
case "always_on":
sampler = new AlwaysOnSampler();
break;
case "always_off":
sampler = new AlwaysOffSampler();
break;
case "traceidratio":
sampler = new TraceIdRatioBasedSampler(samplerArg ?? 1);
break;
case "parentbased_traceidratio":
sampler = new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(samplerArg ?? 1),
});
break;
case "parentbased_always_on":
sampler = new ParentBasedSampler({
root: new AlwaysOnSampler(),
});
break;
case "parentbased_always_off":
sampler = new ParentBasedSampler({
root: new AlwaysOffSampler(),
});
break;
default:
logger.warn(`Unknown sampler type: ${samplerType}. Using always_on.`);
sampler = new AlwaysOnSampler();
}
// --- Initialize NodeSDK ---
const sdk = new NodeSDK({
sampler,
resource: resourceFromAttributes(resourceAttributes),
// When no OTLP endpoint is configured (e.g. Prometheus-only setups), pass an empty
// spanProcessors array to prevent the SDK from falling back to its default OTLP exporter
// which would attempt connections to localhost:4318 and cause noisy errors.
spanProcessors: traceExporter
? [
new BatchSpanProcessor(traceExporter, {
maxQueueSize: 2048,
maxExportBatchSize: 512,
scheduledDelayMillis: 5000,
exportTimeoutMillis: 30000,
}),
]
: [],
metricReaders: metricReaders.length > 0 ? metricReaders : undefined,
instrumentations: [
getNodeAutoInstrumentations({
// Disable noisy/unnecessary instrumentations
"@opentelemetry/instrumentation-fs": {
enabled: false,
},
"@opentelemetry/instrumentation-dns": {
enabled: false,
},
"@opentelemetry/instrumentation-net": {
enabled: false,
},
// Disable pg instrumentation - PrismaInstrumentation handles DB tracing
"@opentelemetry/instrumentation-pg": {
enabled: false,
},
"@opentelemetry/instrumentation-http": {
// Ignore health/metrics endpoints to reduce noise
ignoreIncomingRequestHook: (req) => {
const url = req.url || "";
return url === "/health" || url.startsWith("/metrics") || url === "/api/v2/health";
},
},
// Enable runtime metrics for Node.js process monitoring
"@opentelemetry/instrumentation-runtime-node": {
enabled: true,
},
}),
// Prisma instrumentation for database query tracing
new PrismaInstrumentation(),
],
const exporter = new PrometheusExporter({
port: env.PROMETHEUS_EXPORTER_PORT ? parseInt(env.PROMETHEUS_EXPORTER_PORT) : 9464,
endpoint: "/metrics",
host: "0.0.0.0", // Listen on all network interfaces
});
// Start the SDK
sdk.start();
const detectedResources = detectResources({
detectors: [envDetector, processDetector, hostDetector],
});
// --- Log initialization status ---
const enabledFeatures: string[] = [];
if (traceExporter) enabledFeatures.push("traces");
if (otlpMetricExporter) enabledFeatures.push("otlp-metrics");
if (prometheusExporter) enabledFeatures.push("prometheus-metrics");
const customResources = resourceFromAttributes({});
const samplerArgStr = process.env.OTEL_TRACES_SAMPLER_ARG || "";
const samplerArgMsg = samplerArgStr ? `, samplerArg=${samplerArgStr}` : "";
const resources = detectedResources.merge(customResources);
if (enabledFeatures.length > 0) {
logger.info(
`OpenTelemetry initialized: service=${serviceName}, version=${serviceVersion}, environment=${environment}, exporters=${enabledFeatures.join("+")}, sampler=${samplerType}${samplerArgMsg}`
);
} else {
logger.info(
`OpenTelemetry initialized (no exporters): service=${serviceName}, version=${serviceVersion}, environment=${environment}`
);
}
const meterProvider = new MeterProvider({
readers: [exporter],
resource: resources,
});
// --- Graceful shutdown ---
// Run before other SIGTERM listeners (logger flush, etc.) so spans are drained first.
process.prependListener("SIGTERM", async () => {
const hostMetrics = new HostMetrics({
name: `otel-metrics`,
meterProvider,
});
registerInstrumentations({
meterProvider,
instrumentations: [new HttpInstrumentation(), new RuntimeNodeInstrumentation()],
});
hostMetrics.start();
process.on("SIGTERM", async () => {
try {
await sdk.shutdown();
// Stop collecting metrics or flush them if needed
await meterProvider.shutdown();
// Possibly close other instrumentation resources
} catch (e) {
logger.error(e, "Error during OpenTelemetry shutdown");
logger.error(e, "Error during graceful shutdown");
} finally {
process.exit(0);
}
});

View File

@@ -5,13 +5,10 @@ export const onRequestError = Sentry.captureRequestError;
export const register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs") {
// Load OpenTelemetry instrumentation when Prometheus metrics or OTLP export is enabled
if (PROMETHEUS_ENABLED || process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
if (PROMETHEUS_ENABLED) {
await import("./instrumentation-node");
}
}
// Sentry init loads after OTEL to avoid TracerProvider conflicts
// Sentry tracing is disabled (tracesSampleRate: 0) -- SigNoz handles distributed tracing
if (process.env.NEXT_RUNTIME === "nodejs" && IS_PRODUCTION && SENTRY_DSN) {
await import("./sentry.server.config");
}

View File

@@ -1,464 +0,0 @@
"use server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import {
TConnectorWithMappings,
THubFieldType,
ZConnectorCreateInput,
ZConnectorFieldMappingCreateInput,
ZConnectorUpdateInput,
getHubFieldTypeFromElementType,
} from "@formbricks/types/connector";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getResponseCountBySurveyId } from "@/lib/response/service";
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 { importCsvData } from "./csv-import";
import { importHistoricalResponses } from "./import";
import {
TMappingsInput,
createConnectorWithMappings,
deleteConnector,
getConnectorWithMappingsById,
updateConnector,
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<typeof ZDeleteConnectorAction>;
}) => {
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 resolveSurveyMappings = async (
surveyId: string,
elementIds: string[]
): Promise<{ surveyId: string; elementId: string; hubFieldType: THubFieldType }[]> => {
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]));
return 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),
};
});
};
const resolveFormbricksMappingsInput = async (
entries: { surveyId: string; elementIds: string[] }[]
): Promise<TMappingsInput> => {
const allMappings = await Promise.all(
entries.map(({ surveyId, elementIds }) => resolveSurveyMappings(surveyId, elementIds))
);
return { type: "formbricks", mappings: allMappings.flat() };
};
const ZFormbricksSurveyMapping = z.object({
surveyId: ZId,
elementIds: z.array(z.string()).min(1),
});
const ZCreateConnectorWithMappingsAction = z
.object({
environmentId: ZId,
connectorInput: ZConnectorCreateInput,
formbricksMappings: z.array(ZFormbricksSurveyMapping).optional(),
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
})
.superRefine((data, ctx) => {
if (data.connectorInput.type === "formbricks") {
if (!data.formbricksMappings?.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["formbricksMappings"],
message: "At least one survey mapping is required for Formbricks connectors",
});
}
} else if (data.connectorInput.type === "csv") {
if (!data.fieldMappings?.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["fieldMappings"],
message: "At least one field mapping is required for CSV connectors",
});
}
}
});
export const createConnectorWithMappingsAction = authenticatedActionClient
.schema(ZCreateConnectorWithMappingsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateConnectorWithMappingsAction>;
}): Promise<TConnectorWithMappings> => {
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?.length) {
await Promise.all(
formbricksMappings.map(async ({ surveyId }) => {
const orgId = await getOrganizationIdFromSurveyId(surveyId);
if (orgId !== organizationId) {
throw new AuthorizationError("You are not authorized to access this survey");
}
})
);
mappingsInput = await resolveFormbricksMappingsInput(formbricksMappings);
} else if (fieldMappings?.length) {
mappingsInput = { type: "field", mappings: fieldMappings };
}
return createConnectorWithMappings(
parsedInput.environmentId,
{ ...parsedInput.connectorInput, createdBy: ctx.user.id },
mappingsInput
);
}
);
const ZUpdateConnectorWithMappingsAction = z.object({
connectorId: ZId,
environmentId: ZId,
connectorInput: ZConnectorUpdateInput,
formbricksMappings: z.array(ZFormbricksSurveyMapping).min(1).optional(),
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
});
export const updateConnectorWithMappingsAction = authenticatedActionClient
.schema(ZUpdateConnectorWithMappingsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateConnectorWithMappingsAction>;
}): Promise<TConnectorWithMappings> => {
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?.length) {
await Promise.all(
parsedInput.formbricksMappings.map(async ({ surveyId }) => {
const orgId = await getOrganizationIdFromSurveyId(surveyId);
if (orgId !== organizationId) {
throw new AuthorizationError("You are not authorized to access this survey");
}
})
);
mappingsInput = await resolveFormbricksMappingsInput(parsedInput.formbricksMappings);
} else if (parsedInput.fieldMappings && parsedInput.fieldMappings.length > 0) {
mappingsInput = { type: "field", mappings: parsedInput.fieldMappings };
}
return updateConnectorWithMappings(
parsedInput.connectorId,
parsedInput.environmentId,
parsedInput.connectorInput,
mappingsInput
);
}
);
const ZDuplicateConnectorAction = z.object({
connectorId: ZId,
environmentId: ZId,
});
export const duplicateConnectorAction = authenticatedActionClient
.schema(ZDuplicateConnectorAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDuplicateConnectorAction>;
}): Promise<TConnectorWithMappings> => {
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),
},
],
});
const source = await getConnectorWithMappingsById(parsedInput.connectorId, parsedInput.environmentId);
if (!source) {
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
}
let mappingsInput: TMappingsInput | undefined;
if (source.type === "formbricks" && source.formbricksMappings.length > 0) {
mappingsInput = {
type: "formbricks",
mappings: source.formbricksMappings.map((m) => ({
surveyId: m.surveyId,
elementId: m.elementId,
hubFieldType: m.hubFieldType,
customFieldLabel: m.customFieldLabel ?? undefined,
})),
};
} else if (source.fieldMappings.length > 0) {
mappingsInput = {
type: "field",
mappings: source.fieldMappings.map((m) => ({
sourceFieldId: m.sourceFieldId,
targetFieldId: m.targetFieldId,
staticValue: m.staticValue ?? undefined,
})),
};
}
return createConnectorWithMappings(
parsedInput.environmentId,
{ name: `${source.name} (copy)`, type: source.type, createdBy: ctx.user.id },
mappingsInput
);
}
);
const ZGetResponseCountAction = z.object({
surveyId: ZId,
environmentId: ZId,
});
export const getResponseCountAction = authenticatedActionClient
.schema(ZGetResponseCountAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetResponseCountAction>;
}): Promise<number> => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
return getResponseCountBySurveyId(parsedInput.surveyId);
}
);
const ZImportHistoricalResponsesAction = z.object({
connectorId: ZId,
environmentId: ZId,
surveyId: ZId,
});
export const importHistoricalResponsesAction = authenticatedActionClient
.schema(ZImportHistoricalResponsesAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZImportHistoricalResponsesAction>;
}) => {
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),
},
],
});
const connector = await getConnectorWithMappingsById(
parsedInput.connectorId,
parsedInput.environmentId
);
if (!connector) {
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
}
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
return importHistoricalResponses(connector, survey);
}
);
const ZImportCsvDataAction = z.object({
connectorId: ZId,
environmentId: ZId,
csvData: z.array(z.record(z.string())).min(1),
});
export const importCsvDataAction = authenticatedActionClient
.schema(ZImportCsvDataAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZImportCsvDataAction>;
}) => {
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),
},
],
});
const connector = await getConnectorWithMappingsById(
parsedInput.connectorId,
parsedInput.environmentId
);
if (!connector) {
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
}
const result = await importCsvData(connector, parsedInput.csvData);
if (result.successes > 0) {
await updateConnector(parsedInput.connectorId, parsedInput.environmentId, {
lastSyncAt: new Date(),
});
}
return result;
}
);

View File

@@ -1,122 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { InvalidInputError } from "@formbricks/types/errors";
import { importCsvData } from "./csv-import";
vi.mock("@/modules/hub", () => ({
createFeedbackRecordsBatch: vi.fn(),
}));
vi.mock("./csv-transform", () => ({
transformCsvRowsToFeedbackRecords: vi.fn(),
}));
const { createFeedbackRecordsBatch } = vi.mocked(await import("@/modules/hub"));
const { transformCsvRowsToFeedbackRecords } = vi.mocked(await import("./csv-transform"));
const NOW = new Date("2026-02-25T10:00:00.000Z");
const makeConnector = (overrides?: Partial<TConnectorWithMappings>): TConnectorWithMappings => ({
id: "conn-1",
createdAt: NOW,
updatedAt: NOW,
name: "CSV Import",
type: "csv",
status: "active",
environmentId: "env-1",
lastSyncAt: null,
createdBy: null,
creatorName: null,
formbricksMappings: [],
fieldMappings: [
{
id: "fm-1",
createdAt: NOW,
connectorId: "conn-1",
environmentId: "env-1",
sourceFieldId: "feedback",
targetFieldId: "value_text",
staticValue: null,
},
{
id: "fm-2",
createdAt: NOW,
connectorId: "conn-1",
environmentId: "env-1",
sourceFieldId: "",
targetFieldId: "source_type",
staticValue: "csv",
},
],
...overrides,
});
describe("importCsvData", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("throws InvalidInputError for non-csv connector", async () => {
const connector = makeConnector({ type: "formbricks" });
await expect(importCsvData(connector, [])).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError when no field mappings configured", async () => {
const connector = makeConnector({ fieldMappings: [] });
await expect(importCsvData(connector, [{ feedback: "test" }])).rejects.toThrow(InvalidInputError);
});
test("returns zeros when all rows are skipped", async () => {
transformCsvRowsToFeedbackRecords.mockReturnValue({ records: [], skipped: 3 });
const result = await importCsvData(makeConnector(), [{ a: "1" }, { a: "2" }, { a: "3" }]);
expect(result).toEqual({ successes: 0, failures: 0, skipped: 3 });
expect(createFeedbackRecordsBatch).not.toHaveBeenCalled();
});
test("sends transformed records to Hub and counts results", async () => {
transformCsvRowsToFeedbackRecords.mockReturnValue({
records: [
{ source_type: "csv", field_id: "q1", field_type: "text" as const, value_text: "Good" },
{ source_type: "csv", field_id: "q2", field_type: "text" as const, value_text: "Bad" },
],
skipped: 1,
});
createFeedbackRecordsBatch.mockResolvedValue({
results: [
{ data: { id: "fb1" }, error: null },
{ data: null, error: { status: 400, message: "Bad request", detail: null } },
],
} as never);
const result = await importCsvData(makeConnector(), [{}, {}, {}]);
expect(result).toEqual({ successes: 1, failures: 1, skipped: 1 });
});
test("processes records in batches of 50", async () => {
const records = Array.from({ length: 120 }, (_, i) => ({
source_type: "csv",
field_id: `q${i}`,
field_type: "text" as const,
value_text: `row ${i}`,
}));
transformCsvRowsToFeedbackRecords.mockReturnValue({ records, skipped: 0 });
createFeedbackRecordsBatch.mockResolvedValue({
results: [{ data: { id: "fb" }, error: null }],
} as never);
await importCsvData(
makeConnector(),
Array.from({ length: 120 }, () => ({}))
);
expect(createFeedbackRecordsBatch).toHaveBeenCalledTimes(3);
expect(createFeedbackRecordsBatch.mock.calls[0][0]).toHaveLength(50);
expect(createFeedbackRecordsBatch.mock.calls[1][0]).toHaveLength(50);
expect(createFeedbackRecordsBatch.mock.calls[2][0]).toHaveLength(20);
});
});

View File

@@ -1,35 +0,0 @@
import "server-only";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { InvalidInputError } from "@formbricks/types/errors";
import { createFeedbackRecordsBatch } from "@/modules/hub";
import { transformCsvRowsToFeedbackRecords } from "./csv-transform";
import { TImportResult } from "./import";
const CSV_BATCH_SIZE = 50;
export const importCsvData = async (
connector: TConnectorWithMappings,
csvRows: Record<string, string>[]
): Promise<TImportResult> => {
if (connector.type !== "csv") {
throw new InvalidInputError("CSV import is only supported for CSV connectors");
}
if (connector.fieldMappings.length === 0) {
throw new InvalidInputError("Connector has no field mappings configured");
}
const { records, skipped } = transformCsvRowsToFeedbackRecords(csvRows, connector.fieldMappings);
let successes = 0;
let failures = 0;
for (let i = 0; i < records.length; i += CSV_BATCH_SIZE) {
const batch = records.slice(i, i + CSV_BATCH_SIZE);
const { results } = await createFeedbackRecordsBatch(batch);
successes += results.filter((r) => r.data !== null).length;
failures += results.filter((r) => r.error !== null).length;
}
return { successes, failures, skipped };
};

View File

@@ -1,214 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { TConnectorFieldMapping } from "@formbricks/types/connector";
import { transformCsvRowToFeedbackRecord, transformCsvRowsToFeedbackRecords } from "./csv-transform";
const NOW = new Date("2026-02-25T10:00:00.000Z");
const makeMapping = (
sourceFieldId: string,
targetFieldId: string,
staticValue?: string
): TConnectorFieldMapping => ({
id: `mapping-${targetFieldId}`,
createdAt: NOW,
connectorId: "conn-1",
environmentId: "env-1",
sourceFieldId,
targetFieldId: targetFieldId as TConnectorFieldMapping["targetFieldId"],
staticValue: staticValue ?? null,
});
const baseMappings: TConnectorFieldMapping[] = [
makeMapping("feedback_text", "value_text"),
makeMapping("question", "field_id"),
makeMapping("", "source_type", "survey"),
makeMapping("", "field_type", "text"),
makeMapping("timestamp", "collected_at"),
];
describe("transformCsvRowToFeedbackRecord", () => {
test("transforms a basic row with all required fields", () => {
const row = {
feedback_text: "Great product!",
question: "q1",
timestamp: "2026-01-15T10:00:00Z",
};
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
expect(result).not.toBeNull();
expect(result!.source_type).toBe("survey");
expect(result!.field_id).toBe("q1");
expect(result!.field_type).toBe("text");
expect(result!.value_text).toBe("Great product!");
expect(result!.collected_at).toBe("2026-01-15T10:00:00.000Z");
});
test("returns null when required fields are missing", () => {
const row = { feedback_text: "Great product!" };
const mappings = [makeMapping("feedback_text", "value_text")];
const result = transformCsvRowToFeedbackRecord(row, mappings);
expect(result).toBeNull();
});
test("coerces value_number from string", () => {
const mappings = [...baseMappings, makeMapping("rating", "value_number")];
const row = {
feedback_text: "Good",
question: "q1",
timestamp: "2026-01-15T10:00:00Z",
rating: "4.5",
};
const result = transformCsvRowToFeedbackRecord(row, mappings);
expect(result!.value_number).toBe(4.5);
});
test("skips value_number when not a valid number", () => {
const mappings = [...baseMappings, makeMapping("rating", "value_number")];
const row = {
feedback_text: "Good",
question: "q1",
timestamp: "2026-01-15T10:00:00Z",
rating: "not-a-number",
};
const result = transformCsvRowToFeedbackRecord(row, mappings);
expect(result!.value_number).toBeUndefined();
});
test("coerces value_boolean from string", () => {
const mappings = [...baseMappings, makeMapping("is_promoter", "value_boolean")];
expect(
transformCsvRowToFeedbackRecord(
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "true" },
mappings
)!.value_boolean
).toBe(true);
expect(
transformCsvRowToFeedbackRecord(
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "0" },
mappings
)!.value_boolean
).toBe(false);
expect(
transformCsvRowToFeedbackRecord(
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "yes" },
mappings
)!.value_boolean
).toBe(true);
});
test("handles $now static value for collected_at", () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
const mappings: TConnectorFieldMapping[] = [
makeMapping("question", "field_id"),
makeMapping("", "source_type", "csv"),
makeMapping("", "field_type", "text"),
makeMapping("", "collected_at", "$now"),
];
const result = transformCsvRowToFeedbackRecord({ question: "q1" }, mappings);
expect(result!.collected_at).toBe(NOW.toISOString());
vi.useRealTimers();
});
test("uses static value over source field", () => {
const mappings: TConnectorFieldMapping[] = [
makeMapping("question", "field_id"),
makeMapping("type_column", "source_type", "always_survey"),
makeMapping("", "field_type", "text"),
makeMapping("timestamp", "collected_at"),
];
const row = { question: "q1", type_column: "review", timestamp: "2026-01-15" };
const result = transformCsvRowToFeedbackRecord(row, mappings);
expect(result!.source_type).toBe("always_survey");
});
test("skips empty string values", () => {
const row = {
feedback_text: "",
question: "q1",
timestamp: "2026-01-15T10:00:00Z",
};
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
expect(result!.value_text).toBeUndefined();
});
test("parses metadata as JSON", () => {
const mappings = [...baseMappings, makeMapping("meta", "metadata")];
const row = {
feedback_text: "test",
question: "q1",
timestamp: "2026-01-15",
meta: '{"device":"mobile","version":"2.1"}',
};
const result = transformCsvRowToFeedbackRecord(row, mappings);
expect(result!.metadata).toEqual({ device: "mobile", version: "2.1" });
});
test("wraps non-JSON metadata in { raw: value }", () => {
const mappings = [...baseMappings, makeMapping("meta", "metadata")];
const row = {
feedback_text: "test",
question: "q1",
timestamp: "2026-01-15",
meta: "just a string",
};
const result = transformCsvRowToFeedbackRecord(row, mappings);
expect(result!.metadata).toEqual({ raw: "just a string" });
});
test("handles invalid date gracefully", () => {
const row = {
feedback_text: "test",
question: "q1",
timestamp: "not-a-date",
};
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
expect(result!.collected_at).toBeUndefined();
});
});
describe("transformCsvRowsToFeedbackRecords", () => {
test("transforms multiple rows and counts skipped", () => {
const rows = [
{ feedback_text: "Good", question: "q1", timestamp: "2026-01-15" },
{ feedback_text: "Bad", question: "q2", timestamp: "2026-01-16" },
{ feedback_text: "No question field" },
];
const mappings: TConnectorFieldMapping[] = [
makeMapping("feedback_text", "value_text"),
makeMapping("question", "field_id"),
makeMapping("", "source_type", "survey"),
makeMapping("", "field_type", "text"),
makeMapping("timestamp", "collected_at"),
];
const { records, skipped } = transformCsvRowsToFeedbackRecords(rows, mappings);
expect(records).toHaveLength(2);
expect(skipped).toBe(1);
expect(records[0].field_id).toBe("q1");
expect(records[1].field_id).toBe("q2");
});
test("returns empty records for empty input", () => {
const { records, skipped } = transformCsvRowsToFeedbackRecords([], baseMappings);
expect(records).toHaveLength(0);
expect(skipped).toBe(0);
});
});

View File

@@ -1,105 +0,0 @@
import { TConnectorFieldMapping, THubTargetField } from "@formbricks/types/connector";
import { FeedbackRecordCreateParams } from "@/modules/hub";
const NUMERIC_FIELDS = new Set<THubTargetField>(["value_number"]);
const BOOLEAN_FIELDS = new Set<THubTargetField>(["value_boolean"]);
const TIMESTAMP_FIELDS = new Set<THubTargetField>(["collected_at", "value_date"]);
const JSON_FIELDS = new Set<THubTargetField>(["metadata"]);
const coerceValue = (value: string, targetField: THubTargetField): string | number | boolean | undefined => {
const trimmed = value.trim();
if (trimmed === "") return undefined;
if (NUMERIC_FIELDS.has(targetField)) {
const parsed = Number.parseFloat(trimmed);
return Number.isNaN(parsed) ? undefined : parsed;
}
if (BOOLEAN_FIELDS.has(targetField)) {
const lower = trimmed.toLowerCase();
if (lower === "true" || lower === "1" || lower === "yes") return true;
if (lower === "false" || lower === "0" || lower === "no") return false;
return undefined;
}
if (TIMESTAMP_FIELDS.has(targetField)) {
const date = new Date(trimmed);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
return trimmed;
};
const resolveValue = (
row: Record<string, string>,
mapping: TConnectorFieldMapping
): string | number | boolean | undefined => {
if (mapping.staticValue) {
if (mapping.staticValue === "$now" && TIMESTAMP_FIELDS.has(mapping.targetFieldId)) {
return new Date().toISOString();
}
return coerceValue(mapping.staticValue, mapping.targetFieldId);
}
const rawValue = row[mapping.sourceFieldId];
if (rawValue === undefined || rawValue === null) return undefined;
return coerceValue(rawValue, mapping.targetFieldId);
};
/**
* Transform a single CSV row into a FeedbackRecord using field mappings.
*
* Each mapping maps a CSV column (sourceFieldId) or a static value to a target field.
* Returns null if required fields (source_type, field_id, field_type) are missing after mapping.
*/
export const transformCsvRowToFeedbackRecord = (
row: Record<string, string>,
mappings: TConnectorFieldMapping[]
): FeedbackRecordCreateParams | null => {
const record: Record<string, string | number | boolean | Record<string, unknown> | undefined> = {};
for (const mapping of mappings) {
const value = resolveValue(row, mapping);
if (value === undefined) continue;
if (JSON_FIELDS.has(mapping.targetFieldId)) {
try {
record[mapping.targetFieldId] = typeof value === "string" ? JSON.parse(value) : value;
} catch {
record[mapping.targetFieldId] = { raw: value };
}
} else {
record[mapping.targetFieldId] = value;
}
}
if (!record.source_type || !record.field_id || !record.field_type) {
return null;
}
return record as unknown as FeedbackRecordCreateParams;
};
/**
* Transform multiple CSV rows into FeedbackRecords.
* Returns the successfully transformed records and a count of skipped rows.
*/
export const transformCsvRowsToFeedbackRecords = (
rows: Record<string, string>[],
mappings: TConnectorFieldMapping[]
): { records: FeedbackRecordCreateParams[]; skipped: number } => {
const records: FeedbackRecordCreateParams[] = [];
let skipped = 0;
for (const row of rows) {
const record = transformCsvRowToFeedbackRecord(row, mappings);
if (record) {
records.push(record);
} else {
skipped++;
}
}
return { records, skipped };
};

View File

@@ -1,148 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { InvalidInputError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { importHistoricalResponses } from "./import";
vi.mock("../response/service", () => ({
getResponses: vi.fn(),
}));
vi.mock("@/modules/hub", () => ({
createFeedbackRecordsBatch: vi.fn(),
}));
vi.mock("./transform", () => ({
transformResponseToFeedbackRecords: vi.fn(),
}));
const { getResponses } = vi.mocked(await import("../response/service"));
const { createFeedbackRecordsBatch } = vi.mocked(await import("@/modules/hub"));
const { transformResponseToFeedbackRecords } = vi.mocked(await import("./transform"));
const ENV_ID = "clxxxxxxxxxxxxxxxx001";
const CONNECTOR_ID = "clxxxxxxxxxxxxxxxx002";
const SURVEY_ID = "clxxxxxxxxxxxxxxxx003";
const NOW = new Date("2026-02-24T10:00:00.000Z");
const mockConnector: TConnectorWithMappings = {
id: CONNECTOR_ID,
createdAt: NOW,
updatedAt: NOW,
name: "Test Connector",
type: "formbricks",
status: "active",
environmentId: ENV_ID,
lastSyncAt: null,
createdBy: null,
creatorName: null,
formbricksMappings: [
{
id: "mapping-1",
createdAt: NOW,
connectorId: CONNECTOR_ID,
environmentId: ENV_ID,
surveyId: SURVEY_ID,
elementId: "el-1",
hubFieldType: "text",
customFieldLabel: null,
},
],
fieldMappings: [],
};
const mockSurvey = { id: SURVEY_ID, blocks: [] } as unknown as TSurvey;
describe("importHistoricalResponses", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("throws InvalidInputError for non-formbricks connector", async () => {
const csvConnector = { ...mockConnector, type: "csv" as const };
await expect(importHistoricalResponses(csvConnector, mockSurvey)).rejects.toThrow(InvalidInputError);
expect(getResponses).not.toHaveBeenCalled();
});
test("returns zeros when there are no responses", async () => {
getResponses.mockResolvedValue([]);
const result = await importHistoricalResponses(mockConnector, mockSurvey);
expect(result).toEqual({ successes: 0, failures: 0, skipped: 0 });
});
test("counts successes and skipped correctly", async () => {
const mockResponses = [{ id: "r1" }, { id: "r2" }, { id: "r3" }];
getResponses.mockResolvedValueOnce(mockResponses as never);
getResponses.mockResolvedValueOnce([]);
transformResponseToFeedbackRecords
.mockReturnValueOnce([{ field: "record1" }] as never)
.mockReturnValueOnce([])
.mockReturnValueOnce([{ field: "record3" }] as never);
createFeedbackRecordsBatch.mockResolvedValue({
results: [
{ data: { id: "fb1" }, error: null },
{ data: { id: "fb2" }, error: null },
],
} as never);
const result = await importHistoricalResponses(mockConnector, mockSurvey);
expect(result.successes).toBe(2);
expect(result.failures).toBe(0);
expect(result.skipped).toBe(1);
});
test("counts failures from Hub API errors", async () => {
const mockResponses = [{ id: "r1" }];
getResponses.mockResolvedValueOnce(mockResponses as never);
getResponses.mockResolvedValueOnce([]);
transformResponseToFeedbackRecords.mockReturnValue([{ field: "record" }] as never);
createFeedbackRecordsBatch.mockResolvedValue({
results: [{ data: null, error: { status: 400, message: "Bad request" } }],
} as never);
const result = await importHistoricalResponses(mockConnector, mockSurvey);
expect(result.successes).toBe(0);
expect(result.failures).toBe(1);
});
test("paginates through responses in batches", async () => {
const batch1 = Array.from({ length: 50 }, (_, i) => ({ id: `r${i}` }));
const batch2 = [{ id: "r50" }];
getResponses.mockResolvedValueOnce(batch1 as never);
getResponses.mockResolvedValueOnce(batch2 as never);
getResponses.mockResolvedValueOnce([]);
transformResponseToFeedbackRecords.mockReturnValue([{ field: "record" }] as never);
createFeedbackRecordsBatch.mockResolvedValue({
results: [{ data: { id: "fb" }, error: null }],
} as never);
await importHistoricalResponses(mockConnector, mockSurvey);
expect(getResponses).toHaveBeenCalledWith(SURVEY_ID, 50, 0);
expect(getResponses).toHaveBeenCalledWith(SURVEY_ID, 50, 50);
});
test("does not call Hub API when all responses are skipped", async () => {
const mockResponses = [{ id: "r1" }, { id: "r2" }];
getResponses.mockResolvedValueOnce(mockResponses as never);
getResponses.mockResolvedValueOnce([]);
transformResponseToFeedbackRecords.mockReturnValue([]);
const result = await importHistoricalResponses(mockConnector, mockSurvey);
expect(createFeedbackRecordsBatch).not.toHaveBeenCalled();
expect(result).toEqual({ successes: 0, failures: 0, skipped: 2 });
});
});

View File

@@ -1,62 +0,0 @@
import "server-only";
import { TConnectorFormbricksMapping, TConnectorWithMappings } from "@formbricks/types/connector";
import { InvalidInputError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { createFeedbackRecordsBatch } from "@/modules/hub";
import { getResponses } from "../response/service";
import { transformResponseToFeedbackRecords } from "./transform";
const IMPORT_BATCH_SIZE = 50;
export type TImportResult = { successes: number; failures: number; skipped: number };
const processBatch = async (
responses: Awaited<ReturnType<typeof getResponses>>,
survey: TSurvey,
mappings: TConnectorFormbricksMapping[]
): Promise<TImportResult> => {
let successes = 0;
let failures = 0;
const expectedRecords = responses.length * mappings.length;
const allRecords = responses.flatMap((response) =>
transformResponseToFeedbackRecords(response, survey, mappings)
);
if (allRecords.length > 0) {
const { results } = await createFeedbackRecordsBatch(allRecords);
successes = results.filter((r) => r.data !== null).length;
failures = results.filter((r) => r.error !== null).length;
}
return { successes, failures, skipped: expectedRecords - allRecords.length };
};
export const importHistoricalResponses = async (
connector: TConnectorWithMappings,
survey: TSurvey
): Promise<TImportResult> => {
if (connector.type !== "formbricks") {
throw new InvalidInputError("Historical import is only supported for Formbricks connectors");
}
let successes = 0;
let failures = 0;
let skipped = 0;
let offset = 0;
while (true) {
const responses = await getResponses(survey.id, IMPORT_BATCH_SIZE, offset);
if (responses.length === 0) break;
const batch = await processBatch(responses, survey, connector.formbricksMappings);
successes += batch.successes;
failures += batch.failures;
skipped += batch.skipped;
if (responses.length < IMPORT_BATCH_SIZE) break;
offset += IMPORT_BATCH_SIZE;
}
return { successes, failures, skipped };
};

View File

@@ -1,211 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
const mockCreateFeedbackRecordsBatch = vi.fn();
vi.mock("@/modules/hub", () => ({
createFeedbackRecordsBatch: (...args: unknown[]) => mockCreateFeedbackRecordsBatch(...args),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("./service", () => ({
getConnectorsBySurveyId: vi.fn(),
updateConnector: vi.fn(),
}));
vi.mock("./transform", () => ({
transformResponseToFeedbackRecords: vi.fn(),
}));
const { getConnectorsBySurveyId, updateConnector } = await import("./service");
const { transformResponseToFeedbackRecords } = await import("./transform");
const { handleConnectorPipeline } = await import("./pipeline-handler");
const mockResponse = {
id: "resp-1",
createdAt: new Date("2026-02-24T10:00:00.000Z"),
surveyId: "survey-1",
data: { "el-1": "answer" },
} as unknown as TResponse;
const mockSurvey = {
id: "survey-1",
name: "Test Survey",
blocks: [{ id: "block-1", name: "Block", elements: [{ id: "el-1", headline: { default: "Question?" } }] }],
} as unknown as TSurvey;
function createConnector(
overrides: Partial<Pick<TConnectorWithMappings, "id" | "formbricksMappings">> = {}
): TConnectorWithMappings {
return {
id: "conn-1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Connector",
type: "formbricks",
status: "active",
environmentId: "env-1",
lastSyncAt: null,
formbricksMappings: [
{
id: "map-1",
createdAt: new Date(),
connectorId: "conn-1",
environmentId: "env-1",
surveyId: "survey-1",
elementId: "el-1",
hubFieldType: "rating",
customFieldLabel: null,
},
],
fieldMappings: [],
...overrides,
} as TConnectorWithMappings;
}
const oneFeedbackRecord = [
{
field_id: "el-1",
field_type: "rating" as const,
source_type: "formbricks",
source_id: "survey-1",
source_name: "Test Survey",
field_label: "Question?",
value_number: 5,
collected_at: "2026-02-24T10:00:00.000Z",
},
];
const noConfigError = {
status: 0,
message: "HUB_API_KEY is not set; Hub integration is disabled.",
detail: "HUB_API_KEY is not set; Hub integration is disabled.",
};
describe("handleConnectorPipeline", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns early when no connectors for survey", async () => {
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([]);
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(transformResponseToFeedbackRecords).not.toHaveBeenCalled();
expect(mockCreateFeedbackRecordsBatch).not.toHaveBeenCalled();
expect(updateConnector).not.toHaveBeenCalled();
});
test("continues when transform returns no feedback records", async () => {
const connector = createConnector();
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([connector]);
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue([]);
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(transformResponseToFeedbackRecords).toHaveBeenCalledWith(
mockResponse,
mockSurvey,
connector.formbricksMappings,
"env-1"
);
expect(mockCreateFeedbackRecordsBatch).not.toHaveBeenCalled();
expect(updateConnector).not.toHaveBeenCalled();
});
test("does not update connector when Hub returns no-config (HUB_API_KEY not set)", async () => {
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
mockCreateFeedbackRecordsBatch.mockResolvedValue({
results: oneFeedbackRecord.map(() => ({ data: null, error: noConfigError })),
});
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(mockCreateFeedbackRecordsBatch).toHaveBeenCalledWith(oneFeedbackRecord);
expect(updateConnector).not.toHaveBeenCalled();
});
test("sends records to Hub and updates lastSyncAt on full success", async () => {
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
mockCreateFeedbackRecordsBatch.mockResolvedValue({
results: [{ data: { id: "hub-1", ...oneFeedbackRecord[0] }, error: null }],
});
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(mockCreateFeedbackRecordsBatch).toHaveBeenCalledWith(oneFeedbackRecord);
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
lastSyncAt: expect.any(Date),
});
});
test("does not update connector when all Hub creates fail", async () => {
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
mockCreateFeedbackRecordsBatch.mockResolvedValue({
results: [
{ data: null, error: { status: 500, message: "Hub unavailable", detail: "Hub unavailable" } },
],
});
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(updateConnector).not.toHaveBeenCalled();
});
test("updates lastSyncAt on partial failure when some creates succeed", async () => {
const twoRecords = [...oneFeedbackRecord, { ...oneFeedbackRecord[0], field_id: "el-2", value_number: 3 }];
const baseMapping = {
createdAt: new Date(),
connectorId: "conn-1",
environmentId: "env-1",
surveyId: "survey-1",
hubFieldType: "rating" as const,
customFieldLabel: null as string | null,
};
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([
createConnector({
formbricksMappings: [
{ ...baseMapping, id: "m1", elementId: "el-1" },
{ ...baseMapping, id: "m2", elementId: "el-2" },
],
}),
]);
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(twoRecords as any);
mockCreateFeedbackRecordsBatch.mockResolvedValue({
results: [
{ data: { id: "hub-1" }, error: null },
{ data: null, error: { status: 429, message: "Rate limited", detail: "Rate limited" } },
],
});
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
lastSyncAt: expect.any(Date),
});
});
test("does not update connector when transform throws", async () => {
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
vi.mocked(transformResponseToFeedbackRecords).mockImplementation(() => {
throw new Error("Transform failed");
});
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(updateConnector).not.toHaveBeenCalled();
});
});

View File

@@ -1,132 +0,0 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { createFeedbackRecordsBatch } from "@/modules/hub";
import { getConnectorsBySurveyId, updateConnector } from "./service";
import { transformResponseToFeedbackRecords } from "./transform";
const getErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error";
const logFailedRecords = (
connectorId: string,
results: Awaited<ReturnType<typeof createFeedbackRecordsBatch>>["results"]
): void => {
for (const [index, result] of results.entries()) {
if (!result.error) continue;
logger.error(
{
connectorId,
feedbackRecordIndex: index,
error: {
status: result.error.status,
message: result.error.message,
detail: result.error.detail,
},
},
"Failed to create FeedbackRecord"
);
}
};
const processConnector = async (
connector: TConnectorWithMappings,
response: TResponse,
survey: TSurvey,
environmentId: string
): Promise<void> => {
const feedbackRecords = transformResponseToFeedbackRecords(
response,
survey,
connector.formbricksMappings,
environmentId
);
if (feedbackRecords.length === 0) {
return;
}
const { results } = await createFeedbackRecordsBatch(feedbackRecords);
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`
);
logFailedRecords(connector.id, results);
} else {
logger.info(
{
connectorId: connector.id,
surveyId: survey.id,
responseId: response.id,
feedbackRecordsCreated: successes,
},
`Connector pipeline: Successfully sent ${successes} FeedbackRecords`
);
}
if (successes > 0) {
await updateConnector(connector.id, environmentId, { lastSyncAt: new Date() });
}
};
/**
* 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.
*
* @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<void> => {
try {
const connectors = await getConnectorsBySurveyId(survey.id);
if (connectors.length === 0) {
return;
}
for (const connector of connectors) {
try {
await processConnector(connector, response, survey, environmentId);
} catch (error) {
logger.error(
{
connectorId: connector.id,
surveyId: survey.id,
responseId: response.id,
error: getErrorMessage(error),
},
"Connector pipeline: Failed to process connector"
);
}
}
} catch (error) {
logger.error(
{
surveyId: survey.id,
responseId: response.id,
error: getErrorMessage(error),
},
"Connector pipeline: Failed to handle connectors"
);
}
};

View File

@@ -1,527 +0,0 @@
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,
createdBy: null,
};
const mockConnectorWithMappingsFromDb = {
...mockConnector,
creator: null,
formbricksMappings: [
{
id: "mapping-1",
createdAt: NOW,
connectorId: CONNECTOR_ID,
environmentId: ENV_ID,
surveyId: SURVEY_ID,
elementId: "el-1",
hubFieldType: "text",
customFieldLabel: null,
},
],
fieldMappings: [],
};
const mockConnectorWithMappings = {
...mockConnector,
creatorName: null,
formbricksMappings: mockConnectorWithMappingsFromDb.formbricksMappings,
fieldMappings: [],
};
describe("getConnectorsWithMappings", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns connectors for the given environment", async () => {
vi.mocked(prisma.connector.findMany).mockResolvedValue([mockConnectorWithMappingsFromDb] 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([mockConnectorWithMappingsFromDb] 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<unknown>)(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(mockConnectorWithMappingsFromDb);
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(mockConnectorWithMappingsFromDb);
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<unknown>)(txMethods);
});
return txMethods;
};
test("updates connector name without changing mappings", async () => {
const tx = setupTransaction();
tx.connector.update.mockResolvedValue(undefined);
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappingsFromDb);
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(mockConnectorWithMappingsFromDb);
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
);
});
});

View File

@@ -1,366 +0,0 @@
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,
createdBy: true,
creator: { select: { name: 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,
createdBy: true,
} satisfies Prisma.ConnectorSelect;
type PrismaConnectorWithCreator = Prisma.ConnectorGetPayload<{ select: typeof selectConnectorWithMappings }>;
const mapConnectorWithMappings = (connector: PrismaConnectorWithCreator): TConnectorWithMappings => {
const { creator, ...rest } = connector;
return { ...rest, creatorName: creator?.name ?? null } as TConnectorWithMappings;
};
export const getConnectorsWithMappings = reactCache(
async (environmentId: string, page?: number): Promise<TConnectorWithMappings[]> => {
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.map(mapConnectorWithMappings);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getConnectorWithMappingsById = reactCache(
async (connectorId: string, environmentId: string): Promise<TConnectorWithMappings | null> => {
validateInputs([connectorId, ZId], [environmentId, ZId]);
try {
const connector = await prisma.connector.findUnique({
where: {
id: connectorId,
environmentId,
},
select: selectConnectorWithMappings,
});
return connector ? mapConnectorWithMappings(connector) : null;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getConnectorsBySurveyId = reactCache(
async (surveyId: string): Promise<TConnectorWithMappings[]> => {
validateInputs([surveyId, ZId]);
try {
const connectors = await prisma.connector.findMany({
where: {
type: "formbricks",
status: "active",
formbricksMappings: {
some: {
surveyId,
},
},
},
select: selectConnectorWithMappings,
});
return connectors.map(mapConnectorWithMappings);
} 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<TConnector> => {
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,
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<TConnector> => {
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<TConnectorWithMappings> => {
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,
createdBy: data.createdBy,
},
});
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 mapConnectorWithMappings(result);
} 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<TConnectorWithMappings> => {
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,
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 mapConnectorWithMappings(result);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.RecordDoesNotExist) {
throw new ResourceNotFoundError("Connector", connectorId);
}
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,316 +0,0 @@
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<string, string>, _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<TConnectorFormbricksMapping> &
Pick<TConnectorFormbricksMapping, "elementId" | "hubFieldType">
): 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");
});
});
});

View File

@@ -1,129 +0,0 @@
import "server-only";
import { TConnectorFormbricksMapping, THubFieldType } from "@formbricks/types/connector";
import { TResponse, TResponseData, TResponseDataValue } from "@formbricks/types/responses";
import { TSurveyElement } 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 type { FeedbackRecordCreateParams } from "@/modules/hub";
const getHeadlineFromElement = (element?: TSurveyElement): string => {
if (!element?.headline) return "Untitled";
const raw = getLocalizedValue(element.headline, "default");
return getTextContent(raw) || "Untitled";
};
function extractResponseValue(responseData: TResponseData, elementId: string): TResponseDataValue {
if (!responseData || typeof responseData !== "object") return undefined;
return responseData[elementId];
}
const convertValueToHubFields = (
value: TResponseDataValue,
hubFieldType: THubFieldType
): Partial<
Pick<FeedbackRecordCreateParams, "value_text" | "value_number" | "value_boolean" | "value_date">
> => {
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 FeedbackRecord payloads.
* Called from the pipeline handler when a response is created/finished.
*/
export function transformResponseToFeedbackRecords(
response: TResponse,
survey: TSurvey,
mappings: TConnectorFormbricksMapping[],
tenantId?: string
): FeedbackRecordCreateParams[] {
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: FeedbackRecordCreateParams[] = [];
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: FeedbackRecordCreateParams = {
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;
}

View File

@@ -41,9 +41,6 @@ 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;

View File

@@ -33,8 +33,6 @@ 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(),
HUB_API_KEY: z.string().optional(),
IMPRINT_URL: z
.string()
.url()
@@ -57,6 +55,7 @@ export const env = createEnv({
OIDC_DISPLAY_NAME: z.string().optional(),
OIDC_ISSUER: z.string().optional(),
OIDC_SIGNING_ALGORITHM: z.string().optional(),
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
REDIS_URL:
process.env.NODE_ENV === "test"
? z.string().optional()
@@ -163,8 +162,6 @@ 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,
@@ -177,6 +174,7 @@ export const env = createEnv({
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,

View File

@@ -167,12 +167,6 @@ export const createEnvironment = async (
description: "Your contact's last name",
type: "default",
},
{
key: "language",
name: "Language",
description: "The language preference of a contact",
type: "default",
},
],
},
},

View File

@@ -48,7 +48,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -106,7 +106,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -171,7 +171,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -196,7 +196,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -250,7 +250,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -324,7 +324,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -378,7 +378,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -403,7 +403,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -448,7 +448,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,

View File

@@ -22,7 +22,7 @@ const selectProject = {
config: true,
placement: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
environments: true,
styling: true,
logo: true,

View File

@@ -1,6 +1,5 @@
// https://github.com/airbnb/javascript/#naming--uppercase
import { TProjectStyling } from "@formbricks/types/project";
import { isLight, mixColor } from "@/lib/utils/colors";
export const COLOR_DEFAULTS = {
brandColor: "#64748b",
@@ -12,209 +11,32 @@ export const COLOR_DEFAULTS = {
highlightBorderColor: "#64748b",
} as const;
const DEFAULT_BRAND_COLOR = "#64748b";
/**
* Derives a complete set of suggested color values from a single brand color.
*
* Used by the project-level "Suggest Colors" button **and** to build
* `STYLE_DEFAULTS` so that a fresh install always has colours that are
* visually cohesive with the default brand.
*
* The returned object is a flat map of form-field paths to values so it
* can be spread directly into form defaults or applied via `form.setValue`.
*/
export const getSuggestedColors = (brandColor: string = DEFAULT_BRAND_COLOR) => {
// Question / dark text: brand darkened with black (visible brand tint)
const questionColor = mixColor(brandColor, "#000000", 0.35);
// Input / option background: white with noticeable brand tint
const inputBg = mixColor(brandColor, "#ffffff", 0.92);
// Input border: visible brand-tinted border
const inputBorder = mixColor(brandColor, "#ffffff", 0.6);
// Card tones
const cardBg = mixColor(brandColor, "#ffffff", 0.97);
const cardBorder = mixColor(brandColor, "#ffffff", 0.8);
// Page background
const pageBg = mixColor(brandColor, "#ffffff", 0.855);
return {
// General
"brandColor.light": brandColor,
"questionColor.light": questionColor,
// Headlines & Descriptions — use questionColor to match the legacy behaviour
// where all text elements derived their color from questionColor.
"elementHeadlineColor.light": questionColor,
"elementDescriptionColor.light": questionColor,
"elementUpperLabelColor.light": questionColor,
// Buttons — use the brand color so the button matches the user's intent.
"buttonBgColor.light": brandColor,
"buttonTextColor.light": isLight(brandColor) ? "#0f172a" : "#ffffff",
// Inputs
"inputColor.light": inputBg,
"inputBorderColor.light": inputBorder,
"inputTextColor.light": questionColor,
// Options (Radio / Checkbox)
"optionBgColor.light": inputBg,
"optionLabelColor.light": questionColor,
// Card
"cardBackgroundColor.light": cardBg,
"cardBorderColor.light": cardBorder,
// Highlight / Focus
"highlightBorderColor.light": mixColor(brandColor, "#ffffff", 0.25),
// Progress Bar — indicator uses the brand color; track is a lighter tint.
"progressIndicatorBgColor.light": brandColor,
"progressTrackBgColor.light": mixColor(brandColor, "#ffffff", 0.8),
// Background
background: { bg: pageBg, bgType: "color" as const, brightness: 100 },
};
};
// Pre-compute colors derived from the default brand color.
const _colors = getSuggestedColors(DEFAULT_BRAND_COLOR);
/**
* Single source of truth for every styling default.
*
* Color values are derived from the default brand color (#64748b) via
* `getSuggestedColors()`. Non-color values (dimensions, weights, sizes)
* are hardcoded here and must be kept in sync with globals.css.
*
* Used everywhere: form defaults, preview rendering, email templates,
* and as the reset target for "Restore defaults".
*/
export const STYLE_DEFAULTS: TProjectStyling = {
export const defaultStyling: TProjectStyling = {
allowStyleOverwrite: true,
brandColor: { light: _colors["brandColor.light"] },
questionColor: { light: _colors["questionColor.light"] },
inputColor: { light: _colors["inputColor.light"] },
inputBorderColor: { light: _colors["inputBorderColor.light"] },
cardBackgroundColor: { light: _colors["cardBackgroundColor.light"] },
cardBorderColor: { light: _colors["cardBorderColor.light"] },
brandColor: {
light: COLOR_DEFAULTS.brandColor,
},
questionColor: {
light: COLOR_DEFAULTS.questionColor,
},
inputColor: {
light: COLOR_DEFAULTS.inputColor,
},
inputBorderColor: {
light: COLOR_DEFAULTS.inputBorderColor,
},
cardBackgroundColor: {
light: COLOR_DEFAULTS.cardBackgroundColor,
},
cardBorderColor: {
light: COLOR_DEFAULTS.cardBorderColor,
},
isLogoHidden: false,
highlightBorderColor: { light: _colors["highlightBorderColor.light"] },
highlightBorderColor: undefined,
isDarkModeEnabled: false,
roundness: 8,
cardArrangement: { linkSurveys: "simple", appSurveys: "simple" },
// Headlines & Descriptions
elementHeadlineColor: { light: _colors["elementHeadlineColor.light"] },
elementHeadlineFontSize: 16,
elementHeadlineFontWeight: 600,
elementDescriptionColor: { light: _colors["elementDescriptionColor.light"] },
elementDescriptionFontSize: 14,
elementDescriptionFontWeight: 400,
elementUpperLabelColor: { light: _colors["elementUpperLabelColor.light"] },
elementUpperLabelFontSize: 12,
elementUpperLabelFontWeight: 400,
// Inputs
inputTextColor: { light: _colors["inputTextColor.light"] },
inputBorderRadius: 8,
inputHeight: 20,
inputFontSize: 14,
inputPaddingX: 8,
inputPaddingY: 8,
inputPlaceholderOpacity: 0.5,
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
// Buttons
buttonBgColor: { light: _colors["buttonBgColor.light"] },
buttonTextColor: { light: _colors["buttonTextColor.light"] },
buttonBorderRadius: 8,
buttonHeight: "auto",
buttonFontSize: 16,
buttonFontWeight: 500,
buttonPaddingX: 12,
buttonPaddingY: 12,
// Options
optionBgColor: { light: _colors["optionBgColor.light"] },
optionLabelColor: { light: _colors["optionLabelColor.light"] },
optionBorderRadius: 8,
optionPaddingX: 16,
optionPaddingY: 16,
optionFontSize: 14,
// Progress Bar
progressTrackHeight: 8,
progressTrackBgColor: { light: _colors["progressTrackBgColor.light"] },
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
};
/**
* Fills in new v4.7 color fields from legacy v4.6 fields when they are missing.
*
* v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor.
* v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc.
*
* When loading v4.6 data the new fields are absent. Without this helper the
* form would fall back to STYLE_DEFAULTS (derived from the *default* brand
* colour), causing a visible mismatch. This function derives the new fields
* from the actually-saved legacy fields so the preview and form stay coherent.
*
* Only sets a field when the legacy source exists AND the new field is absent.
*/
export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Record<string, unknown> => {
const light = (key: string): string | undefined =>
(saved[key] as { light?: string } | null | undefined)?.light;
const q = light("questionColor");
const b = light("brandColor");
const i = light("inputColor");
return {
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }),
...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }),
...(q && !saved.inputTextColor && { inputTextColor: { light: q } }),
...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }),
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
...(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) } }),
};
};
/**
* Builds a complete TProjectStyling object from a single brand color.
*
* Uses STYLE_DEFAULTS for all non-color properties (dimensions, weights, etc.)
* and derives every color from the given brand color via getSuggestedColors().
*
* Useful when only a brand color is known (e.g. onboarding) and a fully
* coherent styling object is needed for both preview rendering and persistence.
*/
export const buildStylingFromBrandColor = (brandColor: string = DEFAULT_BRAND_COLOR): TProjectStyling => {
const colors = getSuggestedColors(brandColor);
return {
...STYLE_DEFAULTS,
brandColor: { light: colors["brandColor.light"] },
questionColor: { light: colors["questionColor.light"] },
elementHeadlineColor: { light: colors["elementHeadlineColor.light"] },
elementDescriptionColor: { light: colors["elementDescriptionColor.light"] },
elementUpperLabelColor: { light: colors["elementUpperLabelColor.light"] },
buttonBgColor: { light: colors["buttonBgColor.light"] },
buttonTextColor: { light: colors["buttonTextColor.light"] },
inputColor: { light: colors["inputColor.light"] },
inputBorderColor: { light: colors["inputBorderColor.light"] },
inputTextColor: { light: colors["inputTextColor.light"] },
optionBgColor: { light: colors["optionBgColor.light"] },
optionLabelColor: { light: colors["optionLabelColor.light"] },
cardBackgroundColor: { light: colors["cardBackgroundColor.light"] },
cardBorderColor: { light: colors["cardBorderColor.light"] },
highlightBorderColor: { light: colors["highlightBorderColor.light"] },
progressIndicatorBgColor: { light: colors["progressIndicatorBgColor.light"] },
progressTrackBgColor: { light: colors["progressTrackBgColor.light"] },
background: colors.background,
};
cardArrangement: {
linkSurveys: "straight",
appSurveys: "straight",
},
};

View File

@@ -85,7 +85,7 @@ export const mockProject: TProject = {
inAppSurveyBranding: false,
placement: "bottomRight",
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
environments: [],
languages: [],
config: {
@@ -168,7 +168,6 @@ export const mockContactAttributeKey: TContactAttributeKey = {
type: "custom",
description: "mock action class",
isUnique: false,
dataType: "string",
...commonMockProperties,
};

View File

@@ -141,68 +141,5 @@ describe("Time Utilities", () => {
expect(convertDatesInObject("string")).toBe("string");
expect(convertDatesInObject(123)).toBe(123);
});
test("should not convert dates in ignored keys when keysToIgnore is provided", () => {
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
const input = {
createdAt: "2024-03-20T15:30:00",
contactAttributes: {
createdAt: "2024-03-20T16:30:00",
email: "test@example.com",
},
};
const result = convertDatesInObject(input, keysToIgnore);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.contactAttributes.createdAt).toBe("2024-03-20T16:30:00");
expect(result.contactAttributes.email).toBe("test@example.com");
});
test("should not convert dates in variables when keysToIgnore is provided", () => {
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
const input = {
updatedAt: "2024-03-20T15:30:00",
variables: {
createdAt: "2024-03-20T16:30:00",
userId: "123",
},
};
const result = convertDatesInObject(input, keysToIgnore);
expect(result.updatedAt).toBeInstanceOf(Date);
expect(result.variables.createdAt).toBe("2024-03-20T16:30:00");
expect(result.variables.userId).toBe("123");
});
test("should not convert dates in data or meta when keysToIgnore is provided", () => {
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
const input = {
createdAt: "2024-03-20T15:30:00",
data: {
createdAt: "2024-03-20T16:30:00",
},
meta: {
updatedAt: "2024-03-20T17:30:00",
},
};
const result = convertDatesInObject(input, keysToIgnore);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.data.createdAt).toBe("2024-03-20T16:30:00");
expect(result.meta.updatedAt).toBe("2024-03-20T17:30:00");
});
test("should recurse into all keys when keysToIgnore is not provided", () => {
const input = {
createdAt: "2024-03-20T15:30:00",
contactAttributes: {
createdAt: "2024-03-20T16:30:00",
},
};
const result = convertDatesInObject(input);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.contactAttributes.createdAt).toBeInstanceOf(Date);
});
});
});

View File

@@ -151,20 +151,16 @@ export const getTodaysDateTimeFormatted = (seperator: string) => {
return [formattedDate, formattedTime].join(seperator);
};
export const convertDatesInObject = <T>(obj: T, keysToIgnore?: Set<string>): T => {
export const convertDatesInObject = <T>(obj: T): T => {
if (obj === null || typeof obj !== "object") {
return obj; // Return if obj is not an object
}
if (Array.isArray(obj)) {
// Handle arrays by mapping each element through the function
return obj.map((item) => convertDatesInObject(item, keysToIgnore)) as unknown as T;
return obj.map((item) => convertDatesInObject(item)) as unknown as T;
}
const newObj: Record<string, unknown> = {};
const newObj: any = {};
for (const key in obj) {
if (keysToIgnore?.has(key)) {
newObj[key] = obj[key];
continue;
}
if (
(key === "createdAt" || key === "updatedAt") &&
typeof obj[key] === "string" &&
@@ -172,10 +168,10 @@ export const convertDatesInObject = <T>(obj: T, keysToIgnore?: Set<string>): T =
) {
newObj[key] = new Date(obj[key] as unknown as string);
} else if (typeof obj[key] === "object" && obj[key] !== null) {
newObj[key] = convertDatesInObject(obj[key], keysToIgnore);
newObj[key] = convertDatesInObject(obj[key]);
} else {
newObj[key] = obj[key];
}
}
return newObj as T;
return newObj;
};

View File

@@ -1,261 +0,0 @@
import * as Sentry from "@sentry/nextjs";
import { getServerSession } from "next-auth";
import { DEFAULT_SERVER_ERROR_MESSAGE } from "next-safe-action";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
AuthenticationError,
AuthorizationError,
EXPECTED_ERROR_NAMES,
InvalidInputError,
OperationNotAllowedError,
ResourceNotFoundError,
TooManyRequestsError,
UnknownError,
ValidationError,
isExpectedError,
} from "@formbricks/types/errors";
// Mock Sentry
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
// Mock logger — use plain functions for chained calls so vi.resetAllMocks() doesn't break them
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: () => ({ error: vi.fn() }),
warn: vi.fn(),
},
}));
// Mock next-auth
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
// Mock authOptions
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
// Mock user service
vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
// Mock client IP
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: vi.fn(),
}));
// Mock constants
vi.mock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
}));
// Mock audit log types
vi.mock("@/modules/ee/audit-logs/types/audit-log", () => ({
UNKNOWN_DATA: "unknown",
}));
// ── shared helper tests (pure logic, no action client needed) ──────────
describe("isExpectedError (shared helper)", () => {
test("EXPECTED_ERROR_NAMES contains exactly the right error names", () => {
const expected = [
"ResourceNotFoundError",
"AuthorizationError",
"InvalidInputError",
"ValidationError",
"AuthenticationError",
"OperationNotAllowedError",
"TooManyRequestsError",
];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
for (const name of expected) {
expect(EXPECTED_ERROR_NAMES.has(name)).toBe(true);
}
});
test.each([
{ ErrorClass: AuthorizationError, args: ["Not authorized"] },
{ ErrorClass: AuthenticationError, args: ["Not authenticated"] },
{ ErrorClass: TooManyRequestsError, args: ["Rate limit exceeded"] },
{ ErrorClass: ResourceNotFoundError, args: ["Survey", "123"] },
{ ErrorClass: InvalidInputError, args: ["Invalid input"] },
{ ErrorClass: ValidationError, args: ["Invalid data"] },
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
const error = new (ErrorClass as any)(...args);
expect(isExpectedError(error)).toBe(true);
});
test("returns true for serialised errors that only have a matching name", () => {
const serialisedError = new Error("Auth failed");
serialisedError.name = "AuthorizationError";
expect(isExpectedError(serialisedError)).toBe(true);
});
test.each([
{ error: new Error("Something broke"), label: "Error" },
{ error: new TypeError("Cannot read properties"), label: "TypeError" },
{ error: new RangeError("Maximum call stack"), label: "RangeError" },
{ error: new UnknownError("Unknown"), label: "UnknownError" },
])("returns false for $label", ({ error }) => {
expect(isExpectedError(error)).toBe(false);
});
});
// ── integration tests against the real actionClient / authenticatedActionClient ──
describe("actionClient handleServerError", () => {
// Lazily import so mocks are in place first
let actionClient: (typeof import("./index"))["actionClient"];
beforeEach(async () => {
vi.clearAllMocks();
const mod = await import("./index");
actionClient = mod.actionClient;
});
afterEach(() => {
vi.clearAllMocks();
});
// Helper: create and execute an action that throws the given error
const executeThrowingAction = async (error: Error) => {
const action = actionClient.action(async () => {
throw error;
});
return action();
};
describe("expected errors should NOT be reported to Sentry", () => {
test("AuthorizationError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new AuthorizationError("Not authorized"));
expect(result?.serverError).toBe("Not authorized");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("AuthenticationError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new AuthenticationError("Not authenticated"));
expect(result?.serverError).toBe("Not authenticated");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("TooManyRequestsError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new TooManyRequestsError("Rate limit exceeded"));
expect(result?.serverError).toBe("Rate limit exceeded");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("ResourceNotFoundError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new ResourceNotFoundError("Survey", "123"));
expect(result?.serverError).toContain("Survey");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("InvalidInputError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new InvalidInputError("Invalid input"));
expect(result?.serverError).toBe("Invalid input");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("ValidationError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new ValidationError("Invalid data"));
expect(result?.serverError).toBe("Invalid data");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("OperationNotAllowedError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new OperationNotAllowedError("Not allowed"));
expect(result?.serverError).toBe("Not allowed");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
describe("unexpected errors SHOULD be reported to Sentry", () => {
test("generic Error is sent to Sentry and returns default message", async () => {
const error = new Error("Something broke");
const result = await executeThrowingAction(error);
expect(result?.serverError).toBe(DEFAULT_SERVER_ERROR_MESSAGE);
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({ extra: expect.any(Object) })
);
});
test("TypeError is sent to Sentry and returns default message", async () => {
const error = new TypeError("Cannot read properties of undefined");
const result = await executeThrowingAction(error);
expect(result?.serverError).toBe(DEFAULT_SERVER_ERROR_MESSAGE);
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({ extra: expect.any(Object) })
);
});
test("UnknownError is sent to Sentry (not an expected business-logic error)", async () => {
const error = new UnknownError("Unknown error");
const result = await executeThrowingAction(error);
expect(result?.serverError).toBe(DEFAULT_SERVER_ERROR_MESSAGE);
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({ extra: expect.any(Object) })
);
});
});
});
describe("authenticatedActionClient", () => {
let authenticatedActionClient: (typeof import("./index"))["authenticatedActionClient"];
let getUser: (typeof import("@/lib/user/service"))["getUser"];
beforeEach(async () => {
vi.clearAllMocks();
const mod = await import("./index");
authenticatedActionClient = mod.authenticatedActionClient;
const userService = await import("@/lib/user/service");
getUser = userService.getUser;
});
afterEach(() => {
vi.clearAllMocks();
});
test("throws AuthenticationError when there is no session", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const action = authenticatedActionClient.action(async () => "ok");
const result = await action();
// handleServerError catches AuthenticationError and returns its message
expect(result?.serverError).toBe("Not authenticated");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("throws AuthorizationError when user is not found", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } });
vi.mocked(getUser).mockResolvedValue(null as any);
const action = authenticatedActionClient.action(async () => "ok");
const result = await action();
expect(result?.serverError).toBe("User not found");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("executes action successfully when session and user exist", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } });
vi.mocked(getUser).mockResolvedValue({ id: "user-1", name: "Test" } as any);
const action = authenticatedActionClient.action(async () => "success");
const result = await action();
expect(result?.data).toBe("success");
expect(result?.serverError).toBeUndefined();
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});

View File

@@ -3,7 +3,15 @@ import { getServerSession } from "next-auth";
import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@formbricks/logger";
import { AuthenticationError, AuthorizationError, isExpectedError } from "@formbricks/types/errors";
import {
AuthenticationError,
AuthorizationError,
InvalidInputError,
OperationNotAllowedError,
ResourceNotFoundError,
TooManyRequestsError,
UnknownError,
} from "@formbricks/types/errors";
import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
@@ -14,18 +22,24 @@ import { ActionClientCtx } from "./types/context";
export const actionClient = createSafeActionClient({
handleServerError(e, utils) {
const eventId = (utils.ctx as Record<string, any>)?.auditLoggingCtx?.eventId ?? undefined; // keep explicit fallback
if (isExpectedError(e)) {
return e.message;
}
// Only capture unexpected errors to Sentry
Sentry.captureException(e, {
extra: {
eventId,
},
});
if (
e instanceof ResourceNotFoundError ||
e instanceof AuthorizationError ||
e instanceof InvalidInputError ||
e instanceof UnknownError ||
e instanceof AuthenticationError ||
e instanceof OperationNotAllowedError ||
e instanceof TooManyRequestsError
) {
return e.message;
}
// eslint-disable-next-line no-console -- This error needs to be logged for debugging server-side errors
logger.withContext({ eventId }).error(e, "SERVER ERROR");
return DEFAULT_SERVER_ERROR_MESSAGE;

Some files were not shown because too many files have changed in this diff Show More