Compare commits

..

61 Commits

Author SHA1 Message Date
Johannes b123965d93 feat: wire workspace settings to feedback record directories
Integrate feedback record directory selection into workspace settings and creation flows while updating workspace navigation components to expose the new workspace-level destinations.

Made-with: Cursor
2026-04-24 10:38:32 +02:00
Johannes aed47b94a8 feat: refactor feedback sources UI and routing
Rework the feedback sources flow with new source form helpers, question selection components, and a canonical feedback-sources route while retiring the legacy survey selector.

Made-with: Cursor
2026-04-24 10:37:26 +02:00
Johannes ecaa2887b7 refactor: align connector enum with formbricks_survey
Rename connector type usage from formbricks to formbricks_survey across Prisma schema, shared types, and connector service logic to keep enum contracts consistent.

Made-with: Cursor
2026-04-24 10:36:16 +02:00
Johannes ad094b2d4c fix setup + tweak chart & dashboards creation UX 2026-04-22 09:04:42 +02:00
Dhruwang Jariwala b099219244 feat: connect charts with FeedbackRecordDirectories (ENG-665) (#7760) 2026-04-20 16:46:36 +05:30
Dhruwang af82329c4a fix: add padding to FrdPicker component for improved UI 2026-04-17 14:24:23 +05:30
Dhruwang cfff6b1495 feat: connect charts with FeedbackRecordDirectories (ENG-665)
Scope every chart to a specific FeedbackRecordDirectory so Cube.js
queries are filtered by tenant_id. This ensures data isolation when
multiple FRDs exist in a workspace.

- Add feedbackRecordDirectoryId FK to Chart model + migration
- Add tenantId dimension to FeedbackRecords Cube schema
- Inject tenant filter server-side before every Cube.js query execution
- Require FRD selection when creating charts (FrdPicker component)
- Show FRD as immutable field when editing charts
- Pass feedbackRecordDirectoryId through all chart creation flows
- Update tests and add injectTenantFilter test coverage
- Add i18n keys for FRD selection UI to all locale files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 12:52:22 +05:30
Dhruwang 9bc4e69821 Merge remote-tracking branch 'origin/feat/connect-frd-with-connectors' into epic/dashboards
# Conflicts:
#	apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx
#	apps/web/locales/de-DE.json
#	apps/web/locales/nl-NL.json
#	apps/web/locales/ro-RO.json
#	apps/web/locales/ru-RU.json
#	packages/database/schema.prisma
2026-04-17 12:28:10 +05:30
pandeymangg 81b1c036f6 fixes 2026-04-17 10:50:20 +05:30
pandeymangg 635200db78 fix e2e tests 2026-04-16 17:30:57 +05:30
pandeymangg 793320746e fix build 2026-04-16 16:31:15 +05:30
Dhruwang Jariwala 6501041a48 chore: merge epic/v5 into epic/dashboards (#7750) 2026-04-16 16:22:05 +05:30
pandeymangg f5337e77f3 fixes tests 2026-04-16 16:20:55 +05:30
pandeymangg 0192c1ed00 fixes tests 2026-04-16 16:05:31 +05:30
Dhruwang 2e2b13c36b fix: add CUBEJS and OPENAI env vars to turbo.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 16:01:08 +05:30
Dhruwang 95dd4404d1 Merge remote-tracking branch 'origin/epic/v5' into epic/dashboards 2026-04-16 15:54:05 +05:30
pandeymangg 8a9912f839 fixes feedback 2026-04-16 15:40:02 +05:30
Dhruwang Jariwala 016dc3d92a feat: Dashboards & Charts (#7390)
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-16 12:51:08 +04:00
pandeymangg 3a147a2b09 fixes 2026-04-16 11:14:26 +05:30
pandeymangg e159b45911 fixes translations 2026-04-14 18:02:01 +05:30
pandeymangg 96d14b98f0 build fixes 2026-04-14 17:55:44 +05:30
pandeymangg aa90d9fd1a chore: merge with epic/v5 2026-04-14 17:17:14 +05:30
pandeymangg 2ffe79ffd2 chore: merge with epic/v5 2026-04-14 13:23:04 +05:30
Anshuman Pandey cffeb0513e feat: feedback records table (#7422)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-03-09 09:11:18 +01:00
Dhruwang Jariwala bc334c24cf feat: add Formbricks AI toggle to organization settings (#7399)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-05 06:56:57 -08:00
Anshuman Pandey 077a9934ad feat: csv connector (#7361)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-03-05 13:57:14 +01:00
Tiago 1ed8d8076e chore: remove unused postgres-init-dev volume mount from dev compose (#7412) 2026-03-04 17:34:51 +01:00
Dhruwang Jariwala 345b282733 chore: sync epic dashboards with main (#7398) 2026-03-02 13:48:04 +05:30
Dhruwang c7c30a9d58 Merge branch 'epic/dashboards' of https://github.com/formbricks/formbricks into sync/epic-dashboards-with-main 2026-03-02 12:48:41 +05:30
Dhruwang 08510659de chore: merge main into sync branch to update epic/dashboards 2026-03-02 12:45:58 +05:30
Dhruwang Jariwala f8fa29d56e feat: charts ui (#7332)
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-26 12:21:48 +00:00
Anshuman Pandey 8b048c3105 fix: polish formbricks connector (#7348)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-26 13:08:26 +01:00
Anshuman Pandey b2705a4f8f feat: use @formbricks/hub SDK instead of custom hub-client (#7357) 2026-02-26 13:22:52 +04:00
pandeymangg e867caa373 adds tests for hub-client 2026-02-26 14:34:16 +05:30
Theodór Tómas ff6176df0a chore: sync epic/dashboards with main (#7368) 2026-02-26 15:24:39 +07:00
TheodorTomas d0f4228b45 chore: resolve merge conflicts syncing main into epic/dashboards
- segments.ts: take main's sequential WHERE clause building to prevent pool saturation
- package.json: update zod to 3.25.76 from main
- pnpm-lock.yaml: resolve zod version references, keep @suspensive/react from epic
2026-02-26 13:46:39 +07:00
Tiago Farto de79b58648 chore: fix lock file 2026-02-25 17:28:11 +00:00
Tiago Farto 04d528b9b8 chore: fix lock file 2026-02-25 17:22:56 +00:00
Tiago Farto c815b11015 chore: refactored hub client 2026-02-25 17:19:53 +00:00
Tiago Farto 1e7830d850 chore: refactored hub initialization 2026-02-25 13:38:19 +00:00
Tiago Farto 77cd1e9bd1 chore: additional test coverage 2026-02-25 12:46:05 +00:00
Tiago Farto e665227437 feat(connector): use @formbricks/hub SDK instead of custom hub-client
- Remove apps/web/lib/connector/hub-client.ts
- Add @formbricks/hub dependency to apps/web
- pipeline-handler: create Hub client from env (HUB_API_KEY, HUB_API_URL), call SDK create in batch with same results shape
- transform: use FormbricksHub.FeedbackRecordCreateParams from SDK directly

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 12:20:46 +00:00
Dhruwang Jariwala 3a802810e3 feat: Charts list page with demo create/edit and real delete/duplicate (#7353)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-25 11:20:34 +00:00
Dhruwang Jariwala fbbf917093 chore: sync dashboard epic (#7351)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Gulshan <gulshanbahadur002@gmail.com>
2026-02-25 13:24:51 +05:30
Dhruwang a7e42bfd29 Merge main into epic/dashboards to sync with latest changes 2026-02-25 12:11:43 +05:30
Dhruwang 562fdec899 Merge branch 'epic/dashboards' of https://github.com/formbricks/formbricks into epic/dashboards 2026-02-25 12:08:10 +05:30
Harsh Bhat 75e71e39bc feat: Unify POC hackathon (#7169)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-24 17:59:28 +01:00
Tiago 337aedf463 feat: Add Formbricks Hub to Docker Compose and Helm chart (#7284) 2026-02-23 14:35:33 +01:00
Theodór Tómas d670d5de31 feat: (dashboards) listing page (#7330) 2026-02-23 20:26:03 +07:00
Theodór Tómas 5ccb4af249 feat: (dashboards) crud charts/dashboard server actions (#7307)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-23 11:44:26 +05:30
Theodór Tómas 62aa186a81 chore: merge main into dashboard epic (#7321)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-20 12:22:54 +00:00
Dhruwang cb094761ca Merge branch 'main' of https://github.com/formbricks/formbricks into epic/dashboards 2026-02-20 17:38:35 +05:30
Theodór Tómas f35e54f21d feat: (dashboards) adding analysis tab to sidebar along with placeholder pages (#7311) 2026-02-20 09:58:26 +05:30
Theodór Tómas f49f40610b feat: add Cube.js dev setup and analytics client (#7287) 2026-02-18 21:10:52 +07:00
Theodór Tómas 9e754bad9c feat: add Chart, Dashboard, DashboardWidget schema and migration (#7286) 2026-02-18 21:10:36 +07:00
Dhruwang 4dcf6fda40 fix: code rabbit feedback 2026-02-18 18:44:24 +05:30
Dhruwang 1b8ccd7199 feat: add JSON type definitions for Chart and Dashboard fields
Add Zod schemas and TypeScript types for ChartQuery, ChartConfig,
WidgetLayout. ChartQuery mirrors Cube.js REST API query format.
Register types with prisma-json-types-generator.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:17:44 +05:30
Dhruwang 4f9088559f feat: add Cube.js dev setup and analytics client
- Add Cube container to docker-compose.dev.yml (pinned v1.3.21)
- Add Cube server config (cube/cube.js) and FeedbackRecords schema
- Add @cubejs-client/core dependency and singleton client in EE module
- Add CUBEJS_API_URL and CUBEJS_API_TOKEN to .env.example

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:05:47 +05:30
Dhruwang 18550f1d11 feat: link Chart and Dashboard createdBy to User
- Add creator relation on Chart and Dashboard to User
- Add createdBy foreign key constraints in migration (ON DELETE SET NULL)
- Mirror Survey pattern for createdBy user tracking

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:26:42 +05:30
Dhruwang 881cd31f74 feat: add Chart, Dashboard, DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- ChartType: area, bar, line, pie, big_number only
- Remove DashboardStatus and WidgetType (widgets are always charts)
- DashboardWidget requires chartId, remove content/type fields

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:24:48 +05:30
Dhruwang e00405dca2 feat: add Chart, Dashboard, and DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- Add ChartType, DashboardStatus, WidgetType enums
- Add migration 20260128111722 for charts and dashboards tables

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:21:34 +05:30
497 changed files with 10447 additions and 30001 deletions
+6 -25
View File
@@ -32,24 +32,6 @@ CRON_SECRET=
# Set the minimum log level(debug, info, warn, error, fatal)
LOG_LEVEL=info
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
# BULLMQ_WORKER_ENABLED=1
# Set to 1 on web/API pods that only enqueue jobs while a separate BullMQ worker deployment consumes them.
# BULLMQ_EXTERNAL_WORKER_ENABLED=0
# Number of BullMQ worker instances started per Formbricks server process.
# BULLMQ_WORKER_COUNT=1
# Number of concurrent jobs each BullMQ worker can process.
# BULLMQ_WORKER_CONCURRENCY=1
# Survey publish/close scheduling is configured with public build-time env vars because the editor UI
# also needs to render the selected execution time and timezone.
# NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR=0
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE=0
##############
# DATABASE #
##############
@@ -305,15 +287,14 @@ REDIS_URL=redis://localhost:6379
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
#
# Cube connects to the Hub DB. When using docker-compose.dev.yml with the hub network,
# use the container name and internal port. Hub credentials: formbricks/formbricks_dev, db: hub
# CUBEJS_DB_HOST=formbricks_hub_postgres
# Cube connects to the Hub DB. With docker-compose.dev.yml defaults, use the local postgres service.
# CUBEJS_DB_HOST=postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=hub
# CUBEJS_DB_USER=formbricks
# CUBEJS_DB_PASS=formbricks_dev
# CUBEJS_DB_NAME=postgres
# CUBEJS_DB_USER=postgres
# CUBEJS_DB_PASS=postgres
#
# Alternative (when not on same Docker network): host.docker.internal and port 5433
# Alternative (external Hub/Postgres on the hub network): formbricks_hub_postgres, db: hub, user/pass: formbricks/formbricks_dev
# Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here
+1 -1
View File
@@ -23,7 +23,7 @@
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.2",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.17"
}
}
@@ -337,15 +337,6 @@ export const MainNavigation = ({
href: `/workspaces/${workspace.id}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.title"),
href: `/workspaces/${workspace.id}/settings/feedback-record-directories`,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
];
const loadWorkspaces = useCallback(async () => {
@@ -462,22 +453,16 @@ export const MainNavigation = ({
: `/workspaces/${workspace.id}/surveys/`;
const handleWorkspaceChange = (workspaceId: string) => {
const targetPath =
workspaceId === workspace.id ? `/workspaces/${workspace.id}/surveys` : `/workspaces/${workspaceId}/`;
if (workspaceId === workspace.id) return;
startTransition(() => {
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
router.push(`/workspaces/${workspaceId}/`);
});
};
const handleOrganizationChange = (organizationId: string) => {
const targetPath =
organizationId === organization.id
? `/workspaces/${workspace.id}/settings/general`
: `/organizations/${organizationId}/`;
if (organizationId === organization.id) return;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
router.push(`/organizations/${organizationId}/`);
});
};
@@ -534,7 +519,7 @@ export const MainNavigation = ({
);
const switcherIconClasses =
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialWorkspacesLoading =
isWorkspaceDropdownOpen && !hasInitializedWorkspaces && !workspaceLoadError;
@@ -607,20 +592,18 @@ export const MainNavigation = ({
))}
<li className={cn("mt-2 border-t border-slate-100 pt-2", isCollapsed && "border-t-0 pt-0")}>
<ul>
<NavigationLink
href={configurationNavigationItem.href}
isActive={configurationNavigationItem.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={configurationNavigationItem.disabled}
disabledMessage={
configurationNavigationItem.disabled ? disabledNavigationMessage : undefined
}
linkText={configurationNavigationItem.name}>
<configurationNavigationItem.icon strokeWidth={1.5} />
</NavigationLink>
</ul>
<NavigationLink
href={configurationNavigationItem.href}
isActive={configurationNavigationItem.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={configurationNavigationItem.disabled}
disabledMessage={
configurationNavigationItem.disabled ? disabledNavigationMessage : undefined
}
linkText={configurationNavigationItem.name}>
<configurationNavigationItem.icon strokeWidth={1.5} />
</NavigationLink>
</li>
</ul>
</div>
@@ -117,12 +117,8 @@ export const OrganizationBreadcrumb = ({
const workspaceBasePath = `/workspaces/${workspace?.id}`;
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === currentOrganizationId) return;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentWorkspaceId) {
router.push(`/workspaces/${currentWorkspaceId}/settings/general`);
return;
}
router.push(`/organizations/${organizationId}/`);
});
};
@@ -148,6 +144,12 @@ export const OrganizationBreadcrumb = ({
label: t("common.members_and_teams"),
href: `${workspaceBasePath}/settings/teams`,
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.nav_label"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
hidden: isMember,
},
{
id: "api-keys",
label: t("common.api_keys"),
@@ -179,15 +181,6 @@ export const OrganizationBreadcrumb = ({
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.title"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
];
return (
@@ -163,13 +163,9 @@ export const WorkspaceBreadcrumb = ({
}
const handleWorkspaceChange = (workspaceId: string) => {
const targetPath =
workspaceId === currentWorkspaceId
? `/workspaces/${currentWorkspaceId}/surveys`
: `/workspaces/${workspaceId}/`;
if (workspaceId === currentWorkspaceId) return;
startTransition(() => {
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
router.push(`/workspaces/${workspaceId}/`);
});
};
@@ -8,7 +8,7 @@ import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/type
import { Badge } from "@/modules/ui/components/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled">;
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
type TFeatureDefinition = {
key: TPublicLicenseFeatureKey;
@@ -61,16 +61,6 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
labelKey: t("workspace.settings.enterprise.license_feature_spam_protection"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
},
{
key: "aiSmartTools",
labelKey: t("workspace.settings.general.ai_smart_tools_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "aiDataAnalysis",
labelKey: t("workspace.settings.general.ai_data_analysis_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "auditLogs",
labelKey: t("workspace.settings.enterprise.license_feature_audit_logs"),
@@ -6,32 +6,24 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { updateOrganizationAISettingsAction } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/general/actions";
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { type ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
interface AISettingsToggleProps {
organization: TOrganization;
membershipRole?: TOrganizationRole;
isInstanceAIConfigured: boolean;
hasAIPermission: boolean;
isFormbricksCloud: boolean;
}
export const AISettingsToggle = ({
organization,
membershipRole,
isInstanceAIConfigured,
hasAIPermission,
isFormbricksCloud,
}: Readonly<AISettingsToggleProps>) => {
const { workspace } = useWorkspace();
const workspaceBasePath = `/workspaces/${workspace?.id}`;
const [loadingField, setLoadingField] = useState<string | null>(null);
const { t } = useTranslation();
const router = useRouter();
@@ -86,32 +78,6 @@ export const AISettingsToggle = ({
}
};
const upgradeButtons: [ModalButton, ModalButton] = [
{
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `${workspaceBasePath}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `${workspaceBasePath}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
];
if (!hasAIPermission) {
return (
<UpgradePrompt
title={t("workspace.settings.general.unlock_ai_features_with_a_higher_plan")}
description={t("workspace.settings.general.unlock_ai_features_description")}
buttons={upgradeButtons}
feature="ai_features"
/>
);
}
return (
<div className="space-y-4">
{showInstanceConfigWarning && (
@@ -3,12 +3,7 @@ import { isInstanceAIConfigured } from "@/lib/ai/service";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import {
getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled,
getIsMultiOrgEnabled,
getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -32,14 +27,8 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null;
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
getIsAIDataAnalysisEnabled(organization.id),
]);
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -75,8 +64,6 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
organization={organization}
membershipRole={currentUserMembership?.role}
isInstanceAIConfigured={isInstanceAIConfigured()}
hasAIPermission={hasAIPermission}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
</SettingsCard>
<EmailCustomizationSettings
@@ -1,51 +0,0 @@
"use client";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryCes } from "@formbricks/types/surveys/types";
import { RatingLikeSummary } from "./RatingLikeSummary";
interface CESSummaryProps {
elementSummary: TSurveyElementSummaryCes;
survey: TSurvey;
setFilter: (
elementId: string,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
}
export const CESSummary = ({ elementSummary, survey, setFilter }: CESSummaryProps) => {
const { t } = useTranslation();
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary.element.scale]);
return (
<RatingLikeSummary
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.effort_score")}: {elementSummary.average.toFixed(2)} /{" "}
{elementSummary.element.range}
</div>
</div>
</div>
}
/>
);
};
@@ -1,72 +0,0 @@
"use client";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryCsat } from "@formbricks/types/surveys/types";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { RatingLikeSummary } from "./RatingLikeSummary";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
interface CSATSummaryProps {
elementSummary: TSurveyElementSummaryCsat;
survey: TSurvey;
setFilter: (
elementId: string,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
}
export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryProps) => {
const { t } = useTranslation();
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary.element.scale]);
return (
<RatingLikeSummary
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
</div>
</div>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
<div>
{t("workspace.surveys.summary.csat_satisfied", {
percentage: elementSummary.csat.satisfiedPercentage,
})}
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("workspace.surveys.summary.csat_satisfied_tooltip", {
percentage: elementSummary.csat.satisfiedPercentage,
})}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
/>
);
};
@@ -8,7 +8,7 @@ import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryNps } from "@formbricks/types/surveys/types";
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { convertFloatToNDecimal } from "../lib/utils";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
@@ -39,7 +39,6 @@ const calculateNPSOpacity = (rating: number): number => {
export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const promotersPercentage = convertFloatToNDecimal(elementSummary.promoters.percentage, 2);
const applyFilter = (group: string) => {
const filters = {
@@ -82,23 +81,13 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
elementSummary={elementSummary}
survey={survey}
additionalInfo={
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
<div>
{t("workspace.surveys.summary.promoters")}: {promotersPercentage}%
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("workspace.surveys.summary.nps_promoters_tooltip", {
percentage: promotersPercentage,
})}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
<div>
{t("workspace.surveys.summary.promoters")}:{" "}
{convertFloatToNDecimal(elementSummary.promoters.percentage, 2)}%
</div>
</div>
}
/>
@@ -1,214 +0,0 @@
"use client";
import { BarChart, BarChartHorizontal } from "lucide-react";
import { type JSX, useState } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyElementSummaryCes,
TSurveyElementSummaryCsat,
TSurveyElementSummaryRating,
} from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { RatingScaleLegend } from "./RatingScaleLegend";
type RatingLikeElementSummary =
| TSurveyElementSummaryCes
| TSurveyElementSummaryCsat
| TSurveyElementSummaryRating;
interface RatingLikeSummaryProps {
elementSummary: RatingLikeElementSummary;
survey: TSurvey;
setFilter: (
elementId: string,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
additionalInfo: JSX.Element;
}
export const RatingLikeSummary = ({
elementSummary,
survey,
setFilter,
additionalInfo,
}: RatingLikeSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} additionalInfo={additionalInfo} />
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("workspace.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("workspace.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{elementSummary.responseCount === 0 ? (
<>
<EmptyState text={t("workspace.surveys.summary.no_responses_found")} variant="simple" />
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
) : (
<>
<TooltipProvider delayDuration={200}>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
const range = elementSummary.element.range;
const opacity = 0.3 + (result.rating / range) * 0.7;
const isFirst = index === 0;
const isLast = index === elementSummary.choices.length - 1;
return (
<ClickableBarSegment
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
}}
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
);
})}
</div>
</TooltipProvider>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
return (
<div
key={result.rating}
className="flex flex-col items-center justify-center py-2"
style={{
width: `${result.percentage}%`,
borderRight:
index < elementSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={false}
variant="aggregated"
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{elementSummary.choices.map((result) => (
<div key={result.rating}>
<button
className="w-full cursor-pointer hover:opacity-80"
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={elementSummary.element.isColorCodingEnabled}
variant="individual"
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: result.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">
<div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.dismissed.count })}
</p>
</div>
</div>
</div>
)}
</div>
);
};
@@ -1,12 +1,21 @@
"use client";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryRating } from "@formbricks/types/surveys/types";
import { RatingLikeSummary } from "./RatingLikeSummary";
import { convertFloatToNDecimal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { RatingScaleLegend } from "./RatingScaleLegend";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
interface RatingSummaryProps {
elementSummary: TSurveyElementSummaryRating;
@@ -22,29 +31,196 @@ interface RatingSummaryProps {
export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary.element.scale]);
}, [elementSummary]);
return (
<RatingLikeSummary
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader
elementSummary={elementSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
</div>
</div>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
<div>
CSAT: {elementSummary.csat.satisfiedPercentage}% {t("workspace.surveys.summary.satisfied")}
</div>
</div>
</div>
}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("workspace.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("workspace.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{elementSummary.responseCount === 0 ? (
<>
<EmptyState text={t("workspace.surveys.summary.no_responses_found")} variant="simple" />
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
) : (
<>
<TooltipProvider delayDuration={200}>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
const range = elementSummary.element.range;
const opacity = 0.3 + (result.rating / range) * 0.8;
const isFirst = index === 0;
const isLast = index === elementSummary.choices.length - 1;
return (
<ClickableBarSegment
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
}}
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
);
})}
</div>
</TooltipProvider>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
return (
<div
key={result.rating}
className="flex flex-col items-center justify-center py-2"
style={{
width: `${result.percentage}%`,
borderRight:
index < elementSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={false}
variant="aggregated"
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{elementSummary.choices.map((result) => (
<div key={result.rating}>
<button
className="w-full cursor-pointer hover:opacity-80"
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={elementSummary.element.isColorCodingEnabled}
variant="individual"
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: result.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">
<div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.dismissed.count })}
</p>
</div>
</div>
</div>
}
/>
)}
</div>
);
};
@@ -22,23 +22,8 @@ export const SuccessMessage = ({ survey }: SummaryMetadataProps) => {
const appSetupCompleted = workspace.appSetupCompleted;
useEffect(() => {
const publishSuccessParam = searchParams.get("success");
const scheduledSuccessParam = searchParams.get("scheduled");
if (scheduledSuccessParam) {
toast.success(t("workspace.surveys.summary.survey_scheduled_successfully"), {
id: "survey-schedule-success-toast",
duration: 5000,
position: "bottom-right",
});
const url = new URL(globalThis.location.href);
url.searchParams.delete("scheduled");
globalThis.history.replaceState({}, "", url.toString());
return;
}
if (publishSuccessParam) {
const newSurveyParam = searchParams?.get("success");
if (newSurveyParam && survey && workspace) {
setConfetti(true);
toast.success(
isAppSurvey && !appSetupCompleted
@@ -53,16 +38,16 @@ export const SuccessMessage = ({ survey }: SummaryMetadataProps) => {
);
// Remove success param from url
const url = new URL(globalThis.location.href);
const url = new URL(window.location.href);
url.searchParams.delete("success");
if (survey.type === "link") {
// Add share param to url to open share embed modal
url.searchParams.set("share", "true");
}
globalThis.history.replaceState({}, "", url.toString());
window.history.replaceState({}, "", url.toString());
}
}, [appSetupCompleted, isAppSurvey, searchParams, survey.type, t]);
}, [workspace, isAppSurvey, searchParams, survey, appSetupCompleted, t]);
return <>{confetti && <Confetti />}</>;
};
@@ -13,8 +13,6 @@ import {
SelectedFilterValue,
useResponseFilter,
} from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { CESSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CESSummary";
import { CSATSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CSATSummary";
import { CTASummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
import { CalSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
import { ConsentSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
@@ -158,26 +156,6 @@ export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryL
/>
);
}
if (elementSummary.type === TSurveyElementTypeEnum.CSAT) {
return (
<CSATSummary
key={elementSummary.element.id}
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
/>
);
}
if (elementSummary.type === TSurveyElementTypeEnum.CES) {
return (
<CESSummary
key={elementSummary.element.id}
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
/>
);
}
if (elementSummary.type === TSurveyElementTypeEnum.Consent) {
return (
<ConsentSummary
@@ -4578,611 +4578,3 @@ describe("Cal question type tests", () => {
expect(summary[0].skipped.count).toBe(1); // Counted as skipped
});
});
describe("CSAT question type tests", () => {
test("getElementSummary correctly processes CSAT question with valid responses", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: true,
scale: "smiley",
range: 5,
lowerLabel: { default: "Very unsatisfied" },
upperLabel: { default: "Very satisfied" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-2",
data: { "csat-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-3",
data: { "csat-q1": 2 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-4",
data: { "csat-q1": 1 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "csat-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
expect(summary[0].responseCount).toBe(4);
// Average = (5 + 4 + 2 + 1) / 4 = 3.0
expect(summary[0].average).toBe(3);
// CSAT: satisfied = ratings 4 + 5 = 2 out of 4
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(50);
// Verify choice distribution
const rating5 = summary[0].choices.find((c: any) => c.rating === 5);
expect(rating5.count).toBe(1);
expect(rating5.percentage).toBe(25);
const rating4 = summary[0].choices.find((c: any) => c.rating === 4);
expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25);
const rating2 = summary[0].choices.find((c: any) => c.rating === 2);
expect(rating2.count).toBe(1);
expect(rating2.percentage).toBe(25);
const rating1 = summary[0].choices.find((c: any) => c.rating === 1);
expect(rating1.count).toBe(1);
expect(rating1.percentage).toBe(25);
expect(summary[0].dismissed.count).toBe(0);
});
test("getElementSummary handles CSAT question with dismissed responses", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: false,
scale: "smiley",
range: 5,
lowerLabel: { default: "Very unsatisfied" },
upperLabel: { default: "Very satisfied" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "csat-q1": 3 },
finished: true,
},
{
id: "response-2",
data: {},
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "csat-q1": 2 },
finished: true,
},
{
id: "response-3",
data: {},
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "csat-q1": 4 },
finished: true,
},
] as any;
const dropOff = [
{ elementId: "csat-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
expect(summary[0].responseCount).toBe(1);
expect(summary[0].average).toBe(5);
expect(summary[0].dismissed.count).toBe(2);
expect(summary[0].csat.satisfiedCount).toBe(1);
expect(summary[0].csat.satisfiedPercentage).toBe(100);
});
test("getElementSummary handles CSAT question with no valid responses", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: true,
scale: "smiley",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "other-q": "value" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "csat-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
expect(summary[0].responseCount).toBe(0);
expect(summary[0].average).toBe(0);
expect(summary[0].csat.satisfiedCount).toBe(0);
expect(summary[0].csat.satisfiedPercentage).toBe(0);
expect(summary[0].choices.every((c: any) => c.count === 0)).toBe(true);
});
test("getElementSummary CSAT correctly identifies satisfied ratings (4 and 5 only)", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: true,
scale: "smiley",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
// 3 satisfied (4,5,5), 2 not satisfied (1,3)
const responses = [
{
id: "r1",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "csat-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "csat-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r4",
data: { "csat-q1": 1 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r5",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "csat-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
// satisfied = ratings 4 and 5 = 3 out of 5
expect(summary[0].csat.satisfiedCount).toBe(3);
expect(summary[0].csat.satisfiedPercentage).toBe(60);
});
});
describe("CES question type tests", () => {
test("getElementSummary correctly processes CES question with valid responses (range 5)", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Very difficult" },
upperLabel: { default: "Very easy" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "ces-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-2",
data: { "ces-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-3",
data: { "ces-q1": 2 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-4",
data: { "ces-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "ces-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(4);
// CES = average = (5 + 4 + 2 + 3) / 4 = 3.5
expect(summary[0].average).toBe(3.5);
// Verify choice distribution
const rating5 = summary[0].choices.find((c: any) => c.rating === 5);
expect(rating5.count).toBe(1);
expect(rating5.percentage).toBe(25);
const rating4 = summary[0].choices.find((c: any) => c.rating === 4);
expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25);
const rating3 = summary[0].choices.find((c: any) => c.rating === 3);
expect(rating3.count).toBe(1);
expect(rating3.percentage).toBe(25);
const rating2 = summary[0].choices.find((c: any) => c.rating === 2);
expect(rating2.count).toBe(1);
expect(rating2.percentage).toBe(25);
expect(summary[0].dismissed.count).toBe(0);
// CES has no csat field
expect(summary[0].csat).toBeUndefined();
});
test("getElementSummary correctly processes CES question with range 7", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: true,
scale: "number",
range: 7,
lowerLabel: { default: "Very difficult" },
upperLabel: { default: "Very easy" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "r1",
data: { "ces-q1": 7 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "ces-q1": 6 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "ces-q1": 1 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "ces-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(3);
// CES average = (7 + 6 + 1) / 3 = 4.67
expect(summary[0].average).toBe(4.67);
// Verify 7 choices exist (range 7)
expect(summary[0].choices).toHaveLength(7);
});
test("getElementSummary handles CES question with dismissed responses", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: false,
scale: "number",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "ces-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "ces-q1": 5 },
finished: true,
},
{
id: "response-2",
data: {},
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "ces-q1": 2 },
finished: true,
},
] as any;
const dropOff = [
{ elementId: "ces-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(1);
expect(summary[0].average).toBe(3);
expect(summary[0].dismissed.count).toBe(1);
});
test("getElementSummary handles CES question with no valid responses", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: true,
scale: "number",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "other-q": "value" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "ces-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(0);
expect(summary[0].average).toBe(0);
expect(summary[0].choices.every((c: any) => c.count === 0)).toBe(true);
});
});
@@ -25,6 +25,7 @@ import {
TSurveyElementSummaryOpenText,
TSurveyElementSummaryPictureSelection,
TSurveyElementSummaryRanking,
TSurveyElementSummaryRating,
TSurveyLanguage,
TSurveySummary,
} from "@formbricks/types/surveys/types";
@@ -271,49 +272,6 @@ const checkForI18n = (
return responseData[id];
};
const computeNumericScaleStats = (
elementId: string,
range: number,
responses: TSurveySummaryResponse[]
): {
choices: { rating: number; count: number; percentage: number }[];
choiceCountMap: Record<number, number>;
totalResponseCount: number;
totalRating: number;
dismissed: number;
average: number;
} => {
const choiceCountMap: Record<number, number> = {};
for (let i = 1; i <= range; i++) {
choiceCountMap[i] = 0;
}
let totalResponseCount = 0;
let totalRating = 0;
let dismissed = 0;
responses.forEach((response) => {
const answer = response.data[elementId];
if (typeof answer === "number") {
totalResponseCount++;
choiceCountMap[answer]++;
totalRating += answer;
} else if (response.ttc && response.ttc[elementId] > 0) {
dismissed++;
}
});
const choices = Object.entries(choiceCountMap).map(([label, count]) => ({
rating: Number.parseInt(label),
count,
percentage: totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
}));
const average = totalResponseCount > 0 ? convertFloatTo2Decimal(totalRating / totalResponseCount) : 0;
return { choices, choiceCountMap, totalResponseCount, totalRating, dismissed, average };
};
export const getElementSummary = async (
survey: TSurvey,
elements: TSurveyElement[],
@@ -514,16 +472,72 @@ export const getElementSummary = async (
break;
}
case TSurveyElementTypeEnum.Rating: {
const stats = computeNumericScaleStats(element.id, element.range, responses);
let values: TSurveyElementSummaryRating["choices"] = [];
const choiceCountMap: Record<number, number> = {};
const range = element.range;
for (let i = 1; i <= range; i++) {
choiceCountMap[i] = 0;
}
let totalResponseCount = 0;
let totalRating = 0;
let dismissed = 0;
responses.forEach((response) => {
const answer = response.data[element.id];
if (typeof answer === "number") {
totalResponseCount++;
choiceCountMap[answer]++;
totalRating += answer;
} else if (response.ttc && response.ttc[element.id] > 0) {
dismissed++;
}
});
Object.entries(choiceCountMap).forEach(([label, count]) => {
values.push({
rating: Number.parseInt(label),
count,
percentage:
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
});
});
// Calculate CSAT based on range
let satisfiedCount = 0;
if (range === 3) {
satisfiedCount = choiceCountMap[3] || 0;
} else if (range === 4) {
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
} else if (range === 5) {
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
} else if (range === 6) {
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
} else if (range === 7) {
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
} else if (range === 10) {
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
}
const satisfiedPercentage =
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
summary.push({
type: element.type,
element,
average: stats.average,
responseCount: stats.totalResponseCount,
choices: stats.choices,
dismissed: { count: stats.dismissed },
average: convertFloatTo2Decimal(totalRating / totalResponseCount) || 0,
responseCount: totalResponseCount,
choices: values,
dismissed: {
count: dismissed,
},
csat: {
satisfiedCount,
satisfiedPercentage,
},
});
values = [];
break;
}
case TSurveyElementTypeEnum.NPS: {
@@ -598,40 +612,6 @@ export const getElementSummary = async (
});
break;
}
case TSurveyElementTypeEnum.CSAT: {
const stats = computeNumericScaleStats(element.id, element.range, responses);
// CSAT: top 2 ratings out of 5 are "satisfied"
const satisfiedCount = (stats.choiceCountMap[4] || 0) + (stats.choiceCountMap[5] || 0);
const satisfiedPercentage =
stats.totalResponseCount > 0
? convertFloatTo2Decimal((satisfiedCount / stats.totalResponseCount) * 100)
: 0;
summary.push({
type: element.type,
element,
average: stats.average,
responseCount: stats.totalResponseCount,
choices: stats.choices,
dismissed: { count: stats.dismissed },
csat: { satisfiedCount, satisfiedPercentage },
});
break;
}
case TSurveyElementTypeEnum.CES: {
const stats = computeNumericScaleStats(element.id, element.range, responses);
summary.push({
type: element.type,
element,
average: stats.average,
responseCount: stats.totalResponseCount,
choices: stats.choices,
dismissed: { count: stats.dismissed },
});
break;
}
case TSurveyElementTypeEnum.CTA: {
// Only calculate summary for CTA elements with external buttons (CTR tracking is only meaningful for external links)
if (!element.buttonExternal) {
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
@@ -21,19 +22,18 @@ interface SurveyStatusDropdownProps {
}
export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: SurveyStatusDropdownProps) => {
const { workspace } = useWorkspaceContext();
const { t } = useTranslation();
const router = useRouter();
const isScheduled = survey.status === "paused" && survey.publishOn !== null;
const handleStatusChange = async (status: TSurvey["status"]) => {
const updateSurveyActionResponse = await updateSurveyAction({ ...survey, status });
if (updateSurveyActionResponse?.data) {
const { publishOn, status: resultingStatus } = updateSurveyActionResponse.data;
const isResultScheduled = resultingStatus === "paused" && publishOn !== null;
const resultingStatus = updateSurveyActionResponse.data.status;
const statusToToastMessage: Partial<Record<TSurvey["status"], string>> = {
inProgress: t("common.survey_live"),
paused: isResultScheduled ? t("common.survey_scheduled") : t("common.survey_paused"),
paused: t("common.survey_paused"),
completed: t("common.survey_completed"),
};
@@ -68,10 +68,12 @@ export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: Survey
<SelectTrigger className="w-[170px] bg-white md:w-[200px]">
<SelectValue>
<div className="flex items-center">
<SurveyStatusIndicator status={survey.status} isScheduled={isScheduled} />
{(survey.type === "link" || workspace.appSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
<span className="ml-2 text-sm text-slate-700">
{survey.status === "inProgress" && t("common.in_progress")}
{survey.status === "paused" && (isScheduled ? t("common.scheduled") : t("common.paused"))}
{survey.status === "paused" && t("common.paused")}
{survey.status === "completed" && t("common.completed")}
</span>
</div>
@@ -86,8 +88,8 @@ export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: Survey
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="paused">
<div className="flex w-full items-center justify-center gap-2">
<SurveyStatusIndicator status={"paused"} isScheduled={isScheduled} />
{isScheduled ? t("common.scheduled") : t("common.paused")}
<SurveyStatusIndicator status={"paused"} />
{t("common.paused")}
</div>
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="completed">
@@ -1,8 +0,0 @@
import { type ReactNode } from "react";
import { SurveysQueryClientProvider } from "./query-client-provider";
const SurveysLayout = ({ children }: { children: ReactNode }) => {
return <SurveysQueryClientProvider>{children}</SurveysQueryClientProvider>;
};
export default SurveysLayout;
@@ -1,10 +0,0 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
export const SurveysQueryClientProvider = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
@@ -1,197 +0,0 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError } from "@formbricks/types/errors";
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 { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { createFeedbackRecord, retrieveFeedbackRecord, updateFeedbackRecord } from "@/modules/hub/service";
import type { FeedbackRecordCreateParams, FeedbackRecordUpdateParams } from "@/modules/hub/types";
const ZFeedbackRecordId = z.uuid();
const ZFeedbackRecordFieldType = z.enum([
"text",
"categorical",
"nps",
"csat",
"ces",
"rating",
"number",
"boolean",
"date",
]);
const ZFeedbackRecordMetadata = z.record(z.string(), z.unknown());
const ZFeedbackRecordCreateInput = z.object({
submission_id: z.string().min(1),
tenant_id: ZId,
source_type: z.string().min(1),
field_id: z.string().min(1),
field_type: ZFeedbackRecordFieldType,
collected_at: z.iso.datetime().optional(),
source_id: z.string().optional().nullable(),
source_name: z.string().optional().nullable(),
field_label: z.string().optional().nullable(),
field_group_id: z.string().optional(),
field_group_label: z.string().optional().nullable(),
value_text: z.string().optional().nullable(),
value_number: z.number().optional(),
value_boolean: z.boolean().optional(),
value_date: z.iso.datetime().optional(),
metadata: ZFeedbackRecordMetadata.optional(),
language: z.string().optional(),
user_identifier: z.string().optional(),
});
const ZFeedbackRecordUpdateInput = z
.object({
value_text: z.string().optional().nullable(),
value_number: z.number().optional().nullable(),
value_boolean: z.boolean().optional().nullable(),
value_date: z.iso.datetime().optional().nullable(),
language: z.string().optional().nullable(),
metadata: ZFeedbackRecordMetadata.optional(),
user_identifier: z.string().optional().nullable(),
})
.refine(
(value) => Object.values(value).some((entry) => entry !== undefined),
"At least one field must be provided for update"
);
const ZRetrieveFeedbackRecordAction = z.object({
workspaceId: ZId,
recordId: ZFeedbackRecordId,
});
const ZCreateFeedbackRecordAction = z.object({
workspaceId: ZId,
recordInput: ZFeedbackRecordCreateInput,
});
const ZUpdateFeedbackRecordAction = z.object({
workspaceId: ZId,
recordId: ZFeedbackRecordId,
updateInput: ZFeedbackRecordUpdateInput,
});
const ensureAccess = async (
userId: string,
workspaceId: string,
minPermission: "read" | "readWrite"
): Promise<void> => {
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
await checkAuthorizationUpdated({
userId,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "workspaceTeam",
minPermission,
workspaceId,
},
],
});
};
const getWorkspaceDirectoryIds = async (workspaceId: string): Promise<Set<string>> => {
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
return new Set(directories.map((directory) => directory.id));
};
const assertWorkspaceDirectoryAccess = (directoryIds: Set<string>, tenantId: string): void => {
if (!directoryIds.has(tenantId)) {
throw new AuthorizationError("Invalid feedback record directory for this workspace");
}
};
export const retrieveFeedbackRecordAction = authenticatedActionClient
.inputSchema(ZRetrieveFeedbackRecordAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZRetrieveFeedbackRecordAction>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "read");
const recordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!recordResult.data || recordResult.error) {
throw new Error(recordResult.error?.message || "Failed to retrieve feedback record");
}
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, recordResult.data.tenant_id);
return recordResult.data;
}
);
export const createFeedbackRecordAction = authenticatedActionClient
.inputSchema(ZCreateFeedbackRecordAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateFeedbackRecordAction>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, parsedInput.recordInput.tenant_id);
const createResult = await createFeedbackRecord(
parsedInput.recordInput as unknown as FeedbackRecordCreateParams
);
if (!createResult.data || createResult.error) {
throw new Error(createResult.error?.message || "Failed to create feedback record");
}
return createResult.data;
}
);
export const updateFeedbackRecordAction = authenticatedActionClient
.inputSchema(ZUpdateFeedbackRecordAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateFeedbackRecordAction>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!currentRecordResult.data || currentRecordResult.error) {
throw new Error(currentRecordResult.error?.message || "Failed to retrieve feedback record");
}
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
const updatePayload = Object.fromEntries(
Object.entries(parsedInput.updateInput).filter(([, value]) => value !== undefined)
) as unknown as FeedbackRecordUpdateParams;
const updateResult = await updateFeedbackRecord(parsedInput.recordId, updatePayload);
if (!updateResult.data || updateResult.error) {
throw new Error(updateResult.error?.message || "Failed to update feedback record");
}
return updateResult.data;
}
);
@@ -1,988 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { v7 as uuidv7 } from "uuid";
import { z } from "zod";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { FeedbackRecordData } from "@/modules/hub/types";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/modules/ui/components/sheet";
import { Switch } from "@/modules/ui/components/switch";
import {
createFeedbackRecordAction,
retrieveFeedbackRecordAction,
updateFeedbackRecordAction,
} from "./actions";
type FeedbackRecordDrawerMode = "create" | "edit";
interface FeedbackRecordFormDrawerProps {
mode: FeedbackRecordDrawerMode;
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string;
directories: { id: string; name: string }[];
canWrite: boolean;
recordId?: string;
onSuccess: () => Promise<void> | void;
}
const FIELD_TYPE_OPTIONS = [
"text",
"categorical",
"nps",
"csat",
"ces",
"rating",
"number",
"boolean",
"date",
] as const;
const SOURCE_TYPE_PRESET_OPTIONS = [
"survey",
"review",
"feedback_form",
"support",
"social",
"interview",
"usability_test",
"nps_campaign",
] as const;
const SOURCE_TYPE_CUSTOM_VALUE = "__custom__";
const ZMetadataEntry = z.object({
key: z.string().trim().min(1),
value: z.string(),
});
const ZFeedbackRecordFormValues = z.object({
id: z.string().optional(),
tenant_id: z.string().min(1),
submission_id: z.string().min(1),
collected_at: z.string().min(1),
created_at: z.string().optional(),
updated_at: z.string().optional(),
source_type: z.string().min(1),
source_id: z.string().optional(),
source_name: z.string().optional(),
field_id: z.string().min(1),
field_label: z.string().optional(),
field_type: z.enum(FIELD_TYPE_OPTIONS),
field_group_id: z.string().optional(),
field_group_label: z.string().optional(),
value_text: z.string().optional(),
value_number: z.string().optional(),
value_boolean: z.boolean().optional(),
value_date: z.string().optional(),
language: z.string().optional(),
user_identifier: z.string().optional(),
metadataEntries: z.array(ZMetadataEntry),
});
type TFeedbackRecordFormValues = z.infer<typeof ZFeedbackRecordFormValues>;
const getValueFieldByType = (
fieldType: TFeedbackRecordFormValues["field_type"]
): "value_text" | "value_number" | "value_boolean" | "value_date" => {
switch (fieldType) {
case "boolean":
return "value_boolean";
case "date":
return "value_date";
case "nps":
case "csat":
case "ces":
case "rating":
case "number":
return "value_number";
default:
return "value_text";
}
};
const toLocalDateTimeInput = (isoDate: string): string => {
const date = new Date(isoDate);
if (!Number.isFinite(date.getTime())) {
return "";
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
const toISOOrUndefined = (dateTimeValue: string | undefined): string | undefined => {
if (!dateTimeValue) {
return undefined;
}
const parsed = new Date(dateTimeValue);
if (!Number.isFinite(parsed.getTime())) {
return undefined;
}
return parsed.toISOString();
};
const getCreateDefaults = (directories: { id: string; name: string }[]): TFeedbackRecordFormValues => {
const now = new Date();
const defaultDirectoryId = directories[0]?.id ?? "";
return {
id: "",
tenant_id: defaultDirectoryId,
submission_id: uuidv7(),
collected_at: toLocalDateTimeInput(now.toISOString()),
created_at: "",
updated_at: "",
source_type: "survey",
source_id: "",
source_name: "",
field_id: "",
field_label: "",
field_type: "text",
field_group_id: "",
field_group_label: "",
value_text: "",
value_number: "",
value_boolean: undefined,
value_date: "",
language: "",
user_identifier: "",
metadataEntries: [],
};
};
const mapRecordToValues = (record: FeedbackRecordData): TFeedbackRecordFormValues => {
const metadataEntries = Object.entries(record.metadata ?? {})
.filter(([, value]) => typeof value === "string")
.map(([key, value]) => ({
key,
value: value as string,
}));
return {
id: record.id,
tenant_id: record.tenant_id,
submission_id: record.submission_id,
collected_at: toLocalDateTimeInput(record.collected_at),
created_at: record.created_at ? toLocalDateTimeInput(record.created_at) : "",
updated_at: record.updated_at ? toLocalDateTimeInput(record.updated_at) : "",
source_type: record.source_type,
source_id: record.source_id ?? "",
source_name: record.source_name ?? "",
field_id: record.field_id,
field_label: record.field_label ?? "",
field_type: record.field_type,
field_group_id: record.field_group_id ?? "",
field_group_label: record.field_group_label ?? "",
value_text: record.value_text ?? "",
value_number: record.value_number == null ? "" : String(record.value_number),
value_boolean: record.value_boolean,
value_date: record.value_date ? toLocalDateTimeInput(record.value_date) : "",
language: record.language ?? "",
user_identifier: record.user_identifier ?? "",
metadataEntries,
};
};
const getReadOnlyMetadataEntries = (record: FeedbackRecordData): { key: string; value: string }[] => {
return Object.entries(record.metadata ?? {})
.filter(([, value]) => typeof value !== "string")
.map(([key, value]) => ({
key,
value: JSON.stringify(value),
}));
};
const parseNumberValue = (value: string): number | null => {
if (value.trim() === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const formatSourceType = (sourceType: string, t: (key: string) => string): string => {
switch (sourceType) {
case "formbricks":
case "formbricks_survey":
return t("workspace.unify.formbricks_surveys");
case "csv":
return t("workspace.unify.csv_import");
default:
return sourceType;
}
};
export const FeedbackRecordFormDrawer = ({
mode,
open,
onOpenChange,
workspaceId,
directories,
canWrite,
recordId,
onSuccess,
}: Readonly<FeedbackRecordFormDrawerProps>) => {
const { t } = useTranslation();
const [record, setRecord] = useState<FeedbackRecordData | null>(null);
const [isLoadingRecord, setIsLoadingRecord] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]);
const form = useForm<TFeedbackRecordFormValues>({
resolver: zodResolver(ZFeedbackRecordFormValues),
defaultValues,
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "metadataEntries",
});
const fieldType = form.watch("field_type");
const selectedValueField = getValueFieldByType(fieldType);
const isEditMode = mode === "edit";
const isReadOnly = isEditMode && !canWrite;
const [sourceTypeMode, setSourceTypeMode] = useState<string>("survey");
const [customSourceType, setCustomSourceType] = useState("");
const readOnlyMetadataEntries = useMemo(() => (record ? getReadOnlyMetadataEntries(record) : []), [record]);
const resetForCreate = useCallback(() => {
const nextDefaults = getCreateDefaults(directories);
form.reset(nextDefaults);
setRecord(null);
setSourceTypeMode(nextDefaults.source_type);
setCustomSourceType("");
}, [directories, form]);
useEffect(() => {
if (!open) return;
if (mode === "create") {
resetForCreate();
return;
}
if (!recordId) return;
const loadRecord = async () => {
setIsLoadingRecord(true);
const result = await retrieveFeedbackRecordAction({ workspaceId, recordId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result) || t("workspace.unify.failed_to_load_feedback_records"));
setIsLoadingRecord(false);
return;
}
setRecord(result.data);
form.reset(mapRecordToValues(result.data));
setSourceTypeMode(
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never)
? result.data.source_type
: SOURCE_TYPE_CUSTOM_VALUE
);
setCustomSourceType(
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never) ? "" : result.data.source_type
);
setIsLoadingRecord(false);
};
void loadRecord();
}, [form, mode, open, recordId, resetForCreate, t, workspaceId]);
const requestClose = useCallback(() => {
if (form.formState.isDirty && !isSubmitting) {
setIsDiscardDialogOpen(true);
return;
}
onOpenChange(false);
}, [form.formState.isDirty, isSubmitting, onOpenChange]);
const handleDrawerOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) {
onOpenChange(true);
return;
}
requestClose();
},
[onOpenChange, requestClose]
);
const handleDiscardChanges = () => {
setIsDiscardDialogOpen(false);
onOpenChange(false);
};
const setStrictValueValidationError = (message: string) => {
form.setError(selectedValueField, { type: "manual", message });
};
const handleSubmit = form.handleSubmit(async (values) => {
form.clearErrors();
if (mode === "create") {
const requiredValueError = t("workspace.unify.feedback_record_value_required");
if (selectedValueField === "value_text" && !values.value_text?.trim()) {
setStrictValueValidationError(requiredValueError);
return;
}
if (selectedValueField === "value_number" && parseNumberValue(values.value_number ?? "") == null) {
setStrictValueValidationError(requiredValueError);
return;
}
if (selectedValueField === "value_boolean" && values.value_boolean === undefined) {
setStrictValueValidationError(requiredValueError);
return;
}
if (selectedValueField === "value_date" && !toISOOrUndefined(values.value_date)) {
setStrictValueValidationError(requiredValueError);
return;
}
}
const metadata = Object.fromEntries(
values.metadataEntries
.map((entry) => ({
key: entry.key.trim(),
value: entry.value,
}))
.filter((entry) => entry.key.length > 0)
.map((entry) => [entry.key, entry.value])
);
setIsSubmitting(true);
try {
if (mode === "create") {
const sourceTypeValue =
sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE ? customSourceType.trim() : values.source_type;
const createResult = await createFeedbackRecordAction({
workspaceId,
recordInput: {
submission_id: values.submission_id.trim(),
tenant_id: values.tenant_id,
source_type: sourceTypeValue,
source_id: values.source_id?.trim() ? values.source_id.trim() : null,
source_name: values.source_name?.trim() ? values.source_name.trim() : null,
field_id: values.field_id.trim(),
field_label: values.field_label?.trim() ? values.field_label.trim() : null,
field_type: values.field_type,
field_group_id: values.field_group_id?.trim() || undefined,
field_group_label: values.field_group_label?.trim() ? values.field_group_label.trim() : null,
collected_at: toISOOrUndefined(values.collected_at),
value_text: selectedValueField === "value_text" ? (values.value_text ?? "") : null,
value_number:
selectedValueField === "value_number"
? (parseNumberValue(values.value_number ?? "") ?? undefined)
: undefined,
value_boolean: selectedValueField === "value_boolean" ? values.value_boolean : undefined,
value_date: selectedValueField === "value_date" ? toISOOrUndefined(values.value_date) : undefined,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
language: values.language?.trim() || undefined,
user_identifier: values.user_identifier?.trim() || undefined,
},
});
if (!createResult?.data) {
toast.error(getFormattedErrorMessage(createResult));
setIsSubmitting(false);
return;
}
} else {
if (!recordId) {
setIsSubmitting(false);
return;
}
const preservedMetadata = Object.fromEntries(
Object.entries(record?.metadata ?? {}).filter(([, value]) => typeof value !== "string")
);
const updatePayload: Record<string, unknown> = {
language: values.language?.trim() || null,
user_identifier: values.user_identifier?.trim() || null,
metadata: { ...preservedMetadata, ...metadata },
};
if (selectedValueField === "value_text") {
updatePayload.value_text = values.value_text?.trim() ?? "";
} else if (selectedValueField === "value_number") {
updatePayload.value_number = parseNumberValue(values.value_number ?? "");
} else if (selectedValueField === "value_boolean") {
updatePayload.value_boolean = values.value_boolean ?? null;
} else if (selectedValueField === "value_date") {
updatePayload.value_date = toISOOrUndefined(values.value_date) ?? null;
}
const updateResult = await updateFeedbackRecordAction({
workspaceId,
recordId,
updateInput: updatePayload as never,
});
if (!updateResult?.data) {
toast.error(getFormattedErrorMessage(updateResult));
setIsSubmitting(false);
return;
}
}
toast.success(
mode === "create"
? t("workspace.unify.feedback_record_created_successfully")
: t("workspace.unify.feedback_record_updated_successfully")
);
await onSuccess();
onOpenChange(false);
} finally {
setIsSubmitting(false);
}
});
const drawerTitle =
mode === "create"
? t("workspace.unify.add_feedback_record")
: t("workspace.unify.feedback_record_details");
const drawerDescription =
mode === "create"
? t("workspace.unify.add_feedback_record_description")
: t("workspace.unify.feedback_record_details_description");
const valueBooleanStatus = form.watch("value_boolean");
let valueBooleanLabel = t("common.not_set");
if (valueBooleanStatus === true) {
valueBooleanLabel = t("common.yes");
} else if (valueBooleanStatus === false) {
valueBooleanLabel = t("common.no");
}
return (
<>
<Sheet open={open} onOpenChange={handleDrawerOpenChange}>
<SheetContent className="w-full overflow-y-auto bg-white px-5 sm:max-w-2xl">
<SheetHeader>
<SheetTitle>{drawerTitle}</SheetTitle>
<SheetDescription>{drawerDescription}</SheetDescription>
</SheetHeader>
{isLoadingRecord ? (
<div className="py-8 text-sm text-slate-500">{t("common.loading")}</div>
) : (
<FormProvider {...form}>
<form className="space-y-4 py-4" onSubmit={handleSubmit}>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.id")}</FormLabel>
<FormControl>
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="tenant_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.feedback_record_directory")}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange} disabled>
<SelectTrigger>
<SelectValue
placeholder={t("workspace.unify.select_feedback_record_directory")}
/>
</SelectTrigger>
<SelectContent>
{directories.map((directory) => (
<SelectItem key={directory.id} value={directory.id}>
{directory.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="submission_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.submission_id")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={form.control}
name="collected_at"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.collected_at")}</FormLabel>
<FormControl>
<Input {...field} type="datetime-local" disabled={isEditMode || !canWrite} />
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="created_at"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.created_at")}</FormLabel>
<FormControl>
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="updated_at"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.updated_at")}</FormLabel>
<FormControl>
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
</FormControl>
</FormItem>
)}
/>
</div>
{isEditMode ? (
<FormField
control={form.control}
name="source_type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_type")}</FormLabel>
<FormControl>
<Input {...field} value={formatSourceType(field.value, t)} disabled />
</FormControl>
</FormItem>
)}
/>
) : (
<div className="space-y-2">
<FormLabel>{t("workspace.unify.source_type")}</FormLabel>
<Select
value={sourceTypeMode}
onValueChange={(value) => {
setSourceTypeMode(value);
if (value !== SOURCE_TYPE_CUSTOM_VALUE) {
form.setValue("source_type", value, { shouldDirty: true });
}
}}
disabled={!canWrite}>
<SelectTrigger>
<SelectValue placeholder={t("workspace.unify.select_feedback_record_source_type")} />
</SelectTrigger>
<SelectContent>
{SOURCE_TYPE_PRESET_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
<SelectItem value={SOURCE_TYPE_CUSTOM_VALUE}>
{t("workspace.unify.custom_source_type")}
</SelectItem>
</SelectContent>
</Select>
{sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE && (
<Input
value={customSourceType}
onChange={(event) => {
setCustomSourceType(event.target.value);
form.setValue("source_type", event.target.value, { shouldDirty: true });
}}
placeholder={t("workspace.unify.custom_source_type_placeholder")}
disabled={!canWrite}
/>
)}
<FormError>{form.formState.errors.source_type?.message}</FormError>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="source_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_id")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="source_name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="field_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_id")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={form.control}
name="field_label"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_label")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="field_type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_type")}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={(value) =>
field.onChange(value as TFeedbackRecordFormValues["field_type"])
}
disabled={isEditMode || !canWrite}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPE_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="field_group_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_group_id")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="field_group_label"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_group_label")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="value_text"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.value_text")}</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
disabled={selectedValueField !== "value_text" || isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="value_number"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.value_number")}</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
type="number"
step="any"
disabled={selectedValueField !== "value_number" || isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="value_date"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.value_date")}</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
type="datetime-local"
disabled={selectedValueField !== "value_date" || isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="value_boolean"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.value_boolean")}</FormLabel>
<FormControl>
<div className="flex items-center gap-3 rounded-md border border-slate-200 px-3 py-2">
<Switch
checked={field.value ?? false}
onCheckedChange={(checked) => field.onChange(checked)}
disabled={selectedValueField !== "value_boolean" || isReadOnly || !canWrite}
/>
<span className="text-sm text-slate-600">{valueBooleanLabel}</span>
</div>
</FormControl>
</FormItem>
)}
/>
<FormError>{form.formState.errors[selectedValueField]?.message}</FormError>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<Input {...field} disabled={!canWrite || isReadOnly} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="user_identifier"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.user_identifier")}</FormLabel>
<FormControl>
<Input {...field} disabled={!canWrite || isReadOnly} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>{t("workspace.unify.metadata")}</FormLabel>
{canWrite && !isReadOnly && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => append({ key: "", value: "" })}>
<PlusIcon className="h-4 w-4" />
{t("common.add")}
</Button>
)}
</div>
<div className="space-y-2">
{fields.map((field, index) => (
<div key={field.id} className="grid grid-cols-[1fr_1fr_auto] gap-2">
<FormField
control={form.control}
name={`metadataEntries.${index}.key`}
render={({ field: entryField }) => (
<FormItem>
<FormControl>
<Input
{...entryField}
placeholder={t("workspace.unify.metadata_key")}
disabled={isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`metadataEntries.${index}.value`}
render={({ field: entryField }) => (
<FormItem>
<FormControl>
<Input
{...entryField}
placeholder={t("workspace.unify.metadata_value")}
disabled={isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
{canWrite && !isReadOnly && (
<Button type="button" variant="outline" onClick={() => remove(index)}>
{t("common.delete")}
</Button>
)}
</div>
))}
</div>
{readOnlyMetadataEntries.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-slate-500">
{t("workspace.unify.metadata_read_only_entries")}
</p>
{readOnlyMetadataEntries.map((entry) => (
<div
key={entry.key}
className="grid grid-cols-2 gap-2 rounded-md bg-slate-50 p-2 text-xs">
<span className="font-medium text-slate-700">{entry.key}</span>
<span className="truncate text-slate-600" title={entry.value}>
{entry.value}
</span>
</div>
))}
</div>
)}
</div>
</form>
</FormProvider>
)}
<SheetFooter className="mt-2">
<Button variant="outline" onClick={requestClose} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
{canWrite && (
<Button onClick={handleSubmit} loading={isSubmitting} disabled={isLoadingRecord}>
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
<AlertDialog
open={isDiscardDialogOpen}
setOpen={setIsDiscardDialogOpen}
headerText={t("workspace.unify.discard_feedback_record_changes_title")}
mainText={t("workspace.unify.discard_feedback_record_changes_description")}
confirmBtnLabel={t("common.discard")}
declineBtnLabel={t("common.cancel")}
declineBtnVariant="outline"
onDecline={() => setIsDiscardDialogOpen(false)}
onConfirm={handleDiscardChanges}
/>
</>
);
};
@@ -11,32 +11,22 @@ interface FeedbackRecordsPageClientProps {
workspaceId: string;
initialRecords: FeedbackRecordData[];
frdMap: Record<string, string>;
csvSources: { id: string; name: string }[];
canWrite: boolean;
}
export function FeedbackRecordsPageClient({
workspaceId,
initialRecords,
frdMap,
csvSources,
canWrite,
}: Readonly<FeedbackRecordsPageClientProps>) {
}: FeedbackRecordsPageClientProps) {
const { t } = useTranslation();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.unify.feedback_records")}>
<PageHeader pageTitle={t("workspace.unify.unify_feedback")}>
<UnifyConfigNavigation workspaceId={workspaceId} activeId="feedback-records" />
</PageHeader>
<FeedbackRecordsTable
workspaceId={workspaceId}
initialRecords={initialRecords}
frdMap={frdMap}
csvSources={csvSources}
canWrite={canWrite}
/>
<FeedbackRecordsTable workspaceId={workspaceId} initialRecords={initialRecords} frdMap={frdMap} />
</PageContentWrapper>
);
}
@@ -3,16 +3,13 @@
import { TFunction } from "i18next";
import {
CalendarIcon,
ChevronDownIcon,
HashIcon,
MessageSquareTextIcon,
PlusIcon,
RefreshCwIcon,
ToggleLeftIcon,
TypeIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
@@ -21,16 +18,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { FeedbackRecordData } from "@/modules/hub/types";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { CsvImportModal } from "../sources/components/csv-import-modal";
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
const RECORDS_PER_PAGE = 50;
@@ -54,18 +42,6 @@ const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string):
return "—";
};
const formatSourceType = (sourceType: string, t: TFunction): string => {
switch (sourceType) {
case "formbricks":
case "formbricks_survey":
return t("workspace.unify.formbricks_surveys");
case "csv":
return t("workspace.unify.csv_import");
default:
return sourceType;
}
};
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen) + "…";
@@ -75,48 +51,13 @@ interface FeedbackRecordsTableProps {
workspaceId: string;
initialRecords: FeedbackRecordData[];
frdMap: Record<string, string>;
csvSources: { id: string; name: string }[];
canWrite: boolean;
}
export const FeedbackRecordsTable = ({
workspaceId,
initialRecords,
frdMap,
csvSources,
canWrite,
}: Readonly<FeedbackRecordsTableProps>) => {
export const FeedbackRecordsTable = ({ workspaceId, initialRecords, frdMap }: FeedbackRecordsTableProps) => {
const { t, i18n } = useTranslation();
const [records, setRecords] = useState<FeedbackRecordData[]>(initialRecords);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit");
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
const directories = useMemo(
() =>
Object.entries(frdMap)
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name)),
[frdMap]
);
const feedbackDirectoryName = useMemo(() => {
const directoryNames = Array.from(
new Set(
records
.map((record) => frdMap[record.tenant_id])
.filter((directoryName): directoryName is string => Boolean(directoryName))
)
);
if (directoryNames.length > 0) {
return directoryNames.join(", ");
}
return directories[0]?.name ?? "—";
}, [directories, frdMap, records]);
const handleRefresh = async () => {
if (isRefreshing) return;
@@ -124,33 +65,21 @@ export const FeedbackRecordsTable = ({
setError(null);
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
const directoryIds = Object.keys(frdMap);
const results = await Promise.all(
directoryIds.map((frdId) =>
listFeedbackRecordsAction({
workspaceId,
frdId,
limit: RECORDS_PER_PAGE,
})
)
);
const successfulRecords = results.flatMap((result) => result?.data?.data ?? []);
const result = await listFeedbackRecordsAction({
workspaceId,
limit: RECORDS_PER_PAGE,
});
if (directoryIds.length > 0 && successfulRecords.length === 0) {
const firstErrorResult = results.find((result) => !result?.data);
const errorMessage = firstErrorResult ? getFormattedErrorMessage(firstErrorResult) : undefined;
toast.error(errorMessage ?? t("workspace.unify.failed_to_load_feedback_records"), {
if (!result?.data) {
toast.error(getFormattedErrorMessage(result) ?? t("workspace.unify.failed_to_load_feedback_records"), {
id: toastId,
});
setIsRefreshing(false);
return;
}
const mergedRecords = successfulRecords
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.slice(0, RECORDS_PER_PAGE);
setRecords(mergedRecords);
setRecords(result.data.data);
setIsRefreshing(false);
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
};
@@ -171,193 +100,98 @@ export const FeedbackRecordsTable = ({
const isEmpty = records.length === 0 && !isRefreshing;
const openEditDrawer = (recordId: string) => {
setDrawerMode("edit");
setDrawerRecordId(recordId);
setIsDrawerOpen(true);
};
const openCreateDrawer = () => {
setDrawerMode("create");
setDrawerRecordId(undefined);
setIsDrawerOpen(true);
};
const hasCsvSources = csvSources.length > 0;
return (
<>
<div className="space-y-3">
<div className="space-y-3">
{!isEmpty && (
<div className="flex items-center justify-between">
{isEmpty ? (
<span />
) : (
<p className="text-sm text-slate-500">
{t("workspace.unify.showing_count_loaded", {
count: records.length,
directoryName: feedbackDirectoryName,
})}
</p>
)}
<div className="flex items-center gap-2">
{canWrite &&
(hasCsvSources ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="secondary">
<PlusIcon className="h-4 w-4" />
{t("workspace.unify.add_feedback_record")}
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={openCreateDrawer}>
{t("workspace.unify.add_feedback_record")}
</DropdownMenuItem>
<DropdownMenuSeparator />
{csvSources.map((source) => (
<DropdownMenuItem
key={source.id}
onClick={() => {
setCsvImportSource(source);
}}>
{t("workspace.unify.import_via_source_name", { sourceName: source.name })}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button size="sm" variant="secondary" onClick={openCreateDrawer}>
<PlusIcon className="h-4 w-4" />
{t("workspace.unify.add_feedback_record")}
</Button>
))}
<Button size="sm" asChild>
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
{t("workspace.unify.manage_feedback_sources")}
</Link>
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
aria-label={t("workspace.unify.refresh_feedback_records")}>
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
<p className="text-sm text-slate-500">
{t("workspace.unify.showing_count_loaded", { count: records.length })}
</p>
<Button
variant="secondary"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
aria-label={t("workspace.unify.refresh_feedback_records")}>
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
)}
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[900px]">
<thead>
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[900px]">
<thead>
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
<th className="whitespace-nowrap px-4 py-3">
{t("workspace.unify.feedback_record_directory")}
</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
</tr>
</thead>
{isEmpty ? (
<tbody>
<tr>
<td colSpan={8}>
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
</div>
</td>
</tr>
</thead>
{isEmpty ? (
<tbody>
<tr>
<td colSpan={7}>
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
</div>
</td>
</tr>
</tbody>
) : (
<tbody className="divide-y divide-slate-100">
{records.map((record) => (
<FeedbackRecordRow
key={record.id}
record={record}
workspaceId={workspaceId}
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
t={t}
onClick={() => openEditDrawer(record.id)}
/>
))}
</tbody>
)}
</table>
</div>
</tbody>
) : (
<tbody className="divide-y divide-slate-100">
{records.map((record) => (
<FeedbackRecordRow
key={record.id}
record={record}
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
frdName={record.tenant_id ? (frdMap[record.tenant_id] ?? "—") : "—"}
t={t}
/>
))}
</tbody>
)}
</table>
</div>
</div>
<FeedbackRecordFormDrawer
mode={drawerMode}
open={isDrawerOpen}
onOpenChange={setIsDrawerOpen}
workspaceId={workspaceId}
directories={directories}
canWrite={canWrite}
recordId={drawerMode === "edit" ? drawerRecordId : undefined}
onSuccess={handleRefresh}
/>
{csvImportSource && (
<CsvImportModal
open={csvImportSource !== null}
onOpenChange={(open) => {
if (!open) {
setCsvImportSource(null);
}
}}
connectorId={csvImportSource.id}
workspaceId={workspaceId}
/>
)}
</>
</div>
);
};
const FeedbackRecordRow = ({
record,
workspaceId,
locale,
frdName,
t,
onClick,
}: {
record: FeedbackRecordData;
workspaceId: string;
locale: string;
frdName: string;
t: TFunction;
onClick: () => void;
}) => {
const value = formatValue(record, t, locale);
const isLongValue = value.length > 60;
const isFormbricksSurveySource =
(record.source_type === "formbricks" || record.source_type === "formbricks_survey") && !!record.source_id;
const surveySummaryHref = `/workspaces/${workspaceId}/surveys/${record.source_id}/summary`;
return (
<tr
className="cursor-pointer text-sm text-slate-700 transition-colors hover:bg-slate-50"
onClick={onClick}>
<tr className="text-sm text-slate-700 transition-colors hover:bg-slate-50">
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
</td>
<td className="max-w-[200px] truncate px-4 py-3 text-slate-600" title={frdName}>
{frdName}
</td>
<td className="whitespace-nowrap px-4 py-3">
<Badge text={formatSourceType(record.source_type, t)} type="gray" size="tiny" />
<Badge text={record.source_type} type="gray" size="tiny" />
</td>
<td className="max-w-[150px] truncate px-4 py-3" title={record.source_name ?? undefined}>
{isFormbricksSurveySource ? (
<Link
href={surveySummaryHref}
className="text-slate-700 underline underline-offset-2 hover:text-slate-900"
onClick={(event) => event.stopPropagation()}>
{record.source_name ?? "—"}
</Link>
) : (
<span>{record.source_name ?? "—"}</span>
)}
{record.source_name ?? "—"}
</td>
<td className="max-w-[200px] truncate px-4 py-3" title={record.field_label ?? undefined}>
{record.field_label ?? record.field_id}
@@ -1,5 +1,4 @@
import { notFound } from "next/navigation";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getTranslate } from "@/lingodotdev/server";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { listFeedbackRecords } from "@/modules/hub/service";
@@ -8,9 +7,7 @@ import { FeedbackRecordsPageClient } from "./feedback-records-page-client";
const INITIAL_PAGE_SIZE = 50;
export default async function UnifyFeedbackRecordsPage(
props: Readonly<{ params: Promise<{ workspaceId: string }> }>
) {
export default async function UnifyFeedbackRecordsPage(props: { params: Promise<{ workspaceId: string }> }) {
const t = await getTranslate();
const params = await props.params;
@@ -22,15 +19,11 @@ export default async function UnifyFeedbackRecordsPage(
}
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
const canWrite = isOwner || isManager || hasReadWriteAccess || hasManageAccess;
if (!hasAccess) {
return notFound();
}
const [frds, connectors] = await Promise.all([
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
getConnectorsWithMappings(params.workspaceId),
]);
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId);
const results = await Promise.all(
frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE }))
@@ -45,17 +38,8 @@ export default async function UnifyFeedbackRecordsPage(
.slice(0, INITIAL_PAGE_SIZE);
const frdMap = Object.fromEntries(frds.map((f) => [f.id, f.name]));
const csvSources = connectors
.filter((connector) => connector.type === "csv")
.map((connector) => ({ id: connector.id, name: connector.name }));
return (
<FeedbackRecordsPageClient
workspaceId={params.workspaceId}
initialRecords={merged}
frdMap={frdMap}
csvSources={csvSources}
canWrite={canWrite}
/>
<FeedbackRecordsPageClient workspaceId={params.workspaceId} initialRecords={merged} frdMap={frdMap} />
);
}
@@ -173,11 +173,16 @@ export function ConnectorsSection({
<SettingsCard
title={t("workspace.unify.feedback_sources")}
description={t("workspace.unify.feedback_sources_settings_description")}
buttonInfo={{
text: t("workspace.unify.add_source"),
onClick: () => setIsCreateModalOpen(true),
variant: "default",
}}>
cta={
<CreateConnectorModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreateConnector={handleCreateConnector}
surveys={initialSurveys}
workspaceId={workspaceId}
directories={directories}
/>
}>
<ConnectorsTable
connectors={initialConnectors}
onConnectorClick={setEditingConnector}
@@ -199,16 +204,6 @@ export function ConnectorsSection({
)}
</SettingsCard>
<CreateConnectorModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreateConnector={handleCreateConnector}
surveys={initialSurveys}
workspaceId={workspaceId}
directories={directories}
showTrigger={false}
/>
<EditConnectorModal
connector={editingConnector}
open={editingConnector !== null}
@@ -57,7 +57,6 @@ import { FormbricksQuestionList } from "./formbricks-question-list";
interface CreateConnectorModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
showTrigger?: boolean;
onCreateConnector: (data: {
name: string;
type: TConnectorType;
@@ -117,7 +116,6 @@ const getSelectableQuestionIds = (survey: TUnifySurvey): string[] =>
export const CreateConnectorModal = ({
open,
onOpenChange,
showTrigger = true,
onCreateConnector,
surveys,
workspaceId,
@@ -393,12 +391,10 @@ export const CreateConnectorModal = ({
return (
<>
{showTrigger && (
<Button onClick={() => onOpenChange(true)} size="sm">
{t("workspace.unify.add_source")}
<PlusIcon className="ml-2 h-4 w-4" />
</Button>
)}
<Button onClick={() => onOpenChange(true)} size="sm">
{t("workspace.unify.add_source")}
<PlusIcon className="ml-2 h-4 w-4" />
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
@@ -25,6 +25,7 @@ import {
import { TResponse, TResponseMeta } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { writeData as airtableWriteData } from "@/lib/airtable/service";
import { writeData as googleSheetWriteData } from "@/lib/googleSheet/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -34,7 +35,7 @@ import { writeDataToSlack } from "@/lib/slack/service";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { handleIntegrations } from "./handle-integrations";
import { handleIntegrations } from "./handleIntegrations";
// Mock dependencies
vi.mock("@/lib/airtable/service");
@@ -55,7 +56,6 @@ const questionId2 = "q2";
const questionId3 = "q3_picture";
const hiddenFieldId = "hidden1";
const variableId = "var1";
type TIntegrationPipelineInput = Parameters<typeof handleIntegrations>[1];
const mockPipelineInput = {
workspaceId: "env1",
@@ -93,7 +93,7 @@ const mockPipelineInput = {
},
ttc: {},
} as unknown as TResponse,
} as Parameters<typeof handleIntegrations>[1];
} as TPipelineInput;
const mockSurvey = {
id: surveyId,
@@ -442,114 +442,6 @@ describe("handleIntegrations", () => {
expect(writeNotionData).not.toHaveBeenCalled();
});
test("maps picture selection URLs without mutating the shared response payload", async () => {
vi.mocked(writeNotionData).mockResolvedValue(undefined);
const pipelineInput = structuredClone(mockPipelineInput) as TIntegrationPipelineInput;
await handleIntegrations([mockNotionIntegration], pipelineInput, mockSurvey);
expect(writeNotionData).toHaveBeenCalledWith(
"db1",
expect.objectContaining({
"Column 3": {
url: "http://image.com/1",
},
}),
mockNotionIntegration.config
);
expect(pipelineInput.response.data[questionId3]).toEqual(["picChoice1"]);
});
test("coerces non-string Notion text values and avoids invalid multi-url payloads", async () => {
vi.mocked(writeNotionData).mockResolvedValue(undefined);
const pipelineInput = structuredClone(mockPipelineInput) as TIntegrationPipelineInput;
const pipelineResponseData = pipelineInput.response.data as Record<string, unknown>;
pipelineResponseData[questionId1] = 42;
pipelineResponseData.objectField = { foo: "bar" };
pipelineResponseData.manyUrls = ["https://example.com/a", "https://example.com/b"];
const notionIntegration = structuredClone(mockNotionIntegration);
notionIntegration.config.data[0].mapping = [
{
element: { id: questionId1, name: "Question 1", type: TSurveyQuestionTypeEnum.OpenText },
column: { id: "col_title", name: "Title", type: "title" },
},
{
element: { id: "objectField", name: "Object Field", type: TSurveyQuestionTypeEnum.OpenText },
column: { id: "col_rich", name: "Rich", type: "rich_text" },
},
{
element: { id: "manyUrls", name: "Many Urls", type: TSurveyQuestionTypeEnum.OpenText },
column: { id: "col_url", name: "Url", type: "url" },
},
] as TIntegrationNotionConfigData["mapping"];
await handleIntegrations([notionIntegration], pipelineInput, mockSurvey);
expect(writeNotionData).toHaveBeenCalledWith(
"db1",
expect.objectContaining({
Rich: {
rich_text: [
{
text: {
content: JSON.stringify({ foo: "bar" }),
},
},
],
},
Title: {
title: [
{
text: {
content: "42",
},
},
],
},
Url: {
url: null,
},
}),
notionIntegration.config
);
});
test("sanitizes mixed Notion multi-select values and preserves numeric precision", async () => {
vi.mocked(writeNotionData).mockResolvedValue(undefined);
const pipelineInput = structuredClone(mockPipelineInput) as TIntegrationPipelineInput;
const pipelineResponseData = pipelineInput.response.data as Record<string, unknown>;
pipelineResponseData[questionId2] = ["Choice 1", { name: "Choice, 3" }, 42] as unknown as string[];
pipelineResponseData.numericField = 3.5;
const notionIntegration = structuredClone(mockNotionIntegration);
notionIntegration.config.data[0].mapping = [
{
element: { id: questionId2, name: "Question 2", type: TSurveyQuestionTypeEnum.MultipleChoiceMulti },
column: { id: "col_multi", name: "Multi", type: "multi_select" },
},
{
element: { id: "numericField", name: "Numeric Field", type: TSurveyQuestionTypeEnum.OpenText },
column: { id: "col_number", name: "Number", type: "number" },
},
] as TIntegrationNotionConfigData["mapping"];
await handleIntegrations([notionIntegration], pipelineInput, mockSurvey);
expect(writeNotionData).toHaveBeenCalledWith(
"db1",
expect.objectContaining({
Multi: {
multi_select: [{ name: "Choice 1" }, { name: "Choice 3" }],
},
Number: {
number: 3.5,
},
}),
notionIntegration.config
);
});
test("should return error result on failure", async () => {
const error = new Error("Notion API error");
vi.mocked(writeNotionData).mockRejectedValue(error);
@@ -5,10 +5,11 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { TResponse, TResponseDataValue, TResponseMeta } from "@formbricks/types/responses";
import { TResponseDataValue, TResponseMeta } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { writeData as airtableWriteData } from "@/lib/airtable/service";
import { NOTION_RICH_TEXT_LIMIT } from "@/lib/constants";
import { writeData } from "@/lib/googleSheet/service";
@@ -22,11 +23,6 @@ import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
type TIntegrationPipelineData = {
response: Pick<TResponse, "createdAt" | "data" | "meta" | "variables">;
surveyId: string;
};
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
let result: string[] = [];
if (metadata.source) result.push(`Source: ${metadata.source}`);
@@ -42,38 +38,19 @@ const convertMetaObjectToString = (metadata: TResponseMeta): string => {
return result.join("\n");
};
interface TIntegrationFieldSelection {
elementIds: string[];
includeCreatedAt: boolean;
includeHiddenFields: boolean;
includeMetadata: boolean;
includeVariables: boolean;
}
const toIntegrationFieldSelection = (config: {
elementIds: string[];
includeCreatedAt?: boolean | null;
includeHiddenFields?: boolean | null;
includeMetadata?: boolean | null;
includeVariables?: boolean | null;
}): TIntegrationFieldSelection => ({
elementIds: config.elementIds,
includeCreatedAt: Boolean(config.includeCreatedAt),
includeHiddenFields: Boolean(config.includeHiddenFields),
includeMetadata: Boolean(config.includeMetadata),
includeVariables: Boolean(config.includeVariables),
});
const processDataForIntegration = async (
integrationType: TIntegrationType,
data: TIntegrationPipelineData,
data: TPipelineInput,
survey: TSurvey,
selection: TIntegrationFieldSelection
includeVariables: boolean,
includeMetadata: boolean,
includeHiddenFields: boolean,
includeCreatedAt: boolean,
elementIds: string[]
): Promise<{
responses: string[];
elements: string[];
}> => {
const { elementIds, includeCreatedAt, includeHiddenFields, includeMetadata, includeVariables } = selection;
const ids =
includeHiddenFields && survey.hiddenFields.fieldIds
? [...elementIds, ...survey.hiddenFields.fieldIds]
@@ -107,12 +84,12 @@ const processDataForIntegration = async (
export const handleIntegrations = async (
integrations: TIntegration[],
data: TIntegrationPipelineData,
data: TPipelineInput,
survey: TSurvey
) => {
for (const integration of integrations) {
switch (integration.type) {
case "googleSheets": {
case "googleSheets":
const googleResult = await handleGoogleSheetsIntegration(
integration as TIntegrationGoogleSheets,
data,
@@ -122,15 +99,13 @@ export const handleIntegrations = async (
logger.error(googleResult.error, "Error in google sheets integration");
}
break;
}
case "slack": {
case "slack":
const slackResult = await handleSlackIntegration(integration as TIntegrationSlack, data, survey);
if (!slackResult.ok) {
logger.error(slackResult.error, "Error in slack integration");
}
break;
}
case "airtable": {
case "airtable":
const airtableResult = await handleAirtableIntegration(
integration as TIntegrationAirtable,
data,
@@ -140,21 +115,19 @@ export const handleIntegrations = async (
logger.error(airtableResult.error, "Error in airtable integration");
}
break;
}
case "notion": {
case "notion":
const notionResult = await handleNotionIntegration(integration as TIntegrationNotion, data, survey);
if (!notionResult.ok) {
logger.error(notionResult.error, "Error in notion integration");
}
break;
}
}
}
};
const handleAirtableIntegration = async (
integration: TIntegrationAirtable,
data: TIntegrationPipelineData,
data: TPipelineInput,
survey: TSurvey
): Promise<Result<void, Error>> => {
try {
@@ -165,7 +138,11 @@ const handleAirtableIntegration = async (
"airtable",
data,
survey,
toIntegrationFieldSelection(element)
!!element.includeVariables,
!!element.includeMetadata,
!!element.includeHiddenFields,
!!element.includeCreatedAt,
element.elementIds
);
await airtableWriteData(integration.config.key, element, values.responses, values.elements);
}
@@ -186,7 +163,7 @@ const handleAirtableIntegration = async (
const handleGoogleSheetsIntegration = async (
integration: TIntegrationGoogleSheets,
data: TIntegrationPipelineData,
data: TPipelineInput,
survey: TSurvey
): Promise<Result<void, Error>> => {
try {
@@ -197,7 +174,11 @@ const handleGoogleSheetsIntegration = async (
"googleSheets",
data,
survey,
toIntegrationFieldSelection(element)
!!element.includeVariables,
!!element.includeMetadata,
!!element.includeHiddenFields,
!!element.includeCreatedAt,
element.elementIds
);
const integrationData = structuredClone(integration);
integrationData.config.data.forEach((data) => {
@@ -223,7 +204,7 @@ const handleGoogleSheetsIntegration = async (
const handleSlackIntegration = async (
integration: TIntegrationSlack,
data: TIntegrationPipelineData,
data: TPipelineInput,
survey: TSurvey
): Promise<Result<void, Error>> => {
try {
@@ -234,7 +215,11 @@ const handleSlackIntegration = async (
"slack",
data,
survey,
toIntegrationFieldSelection(element)
!!element.includeVariables,
!!element.includeMetadata,
!!element.includeHiddenFields,
!!element.includeCreatedAt,
element.elementIds
);
await writeDataToSlack(
integration.config.key,
@@ -298,7 +283,7 @@ const createEmptyResponseObject = (responseData: Record<string, unknown>): Recor
const extractResponses = async (
integrationType: TIntegrationType,
pipelineData: TIntegrationPipelineData,
pipelineData: TPipelineInput,
elementIds: string[],
survey: TSurvey
): Promise<{
@@ -344,7 +329,7 @@ const extractResponses = async (
const handleNotionIntegration = async (
integration: TIntegrationNotion,
data: TIntegrationPipelineData,
data: TPipelineInput,
surveyData: TSurvey
): Promise<Result<void, Error>> => {
try {
@@ -371,36 +356,27 @@ const handleNotionIntegration = async (
const buildNotionPayloadProperties = (
mapping: TIntegrationNotionConfigData["mapping"],
data: TIntegrationPipelineData,
data: TPipelineInput,
surveyData: TSurvey
) => {
const properties: any = {};
const normalizedResponses = { ...data.response.data };
const responses = data.response.data;
const surveyElements = getElementsFromBlocks(surveyData.blocks);
const surveyElementsById = new Map(surveyElements.map((element) => [element.id, element] as const));
const pictureSelectionElementIds = new Set(
mapping.filter((m) => m.element.type === TSurveyElementTypeEnum.PictureSelection).map((m) => m.element.id)
);
Object.keys(normalizedResponses).forEach((responseKey) => {
if (!pictureSelectionElementIds.has(responseKey)) {
return;
const mappingElementIds = mapping
.filter((m) => m.element.type === TSurveyElementTypeEnum.PictureSelection)
.map((m) => m.element.id);
Object.keys(responses).forEach((resp) => {
if (mappingElementIds.find((elementId) => elementId === resp)) {
const selectedChoiceIds = responses[resp] as string[];
const pictureElement = surveyElements.find((el) => el.id === resp);
responses[resp] = (pictureElement as any)?.choices
.filter((choice: { id: string; imageUrl: string }) => selectedChoiceIds.includes(choice.id))
.map((choice: { id: string; imageUrl: string }) => resolveStorageUrlAuto(choice.imageUrl));
}
const selectedChoiceIds = normalizedResponses[responseKey];
if (!Array.isArray(selectedChoiceIds)) {
return;
}
const pictureElement = surveyElementsById.get(responseKey);
if (pictureElement?.type !== TSurveyElementTypeEnum.PictureSelection) {
return;
}
normalizedResponses[responseKey] = pictureElement.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
});
mapping.forEach((map) => {
@@ -413,7 +389,7 @@ const buildNotionPayloadProperties = (
[map.column.type]: getValue(map.column.type, data.response.createdAt) || null,
};
} else {
const value = normalizedResponses[map.element.id];
const value = responses[map.element.id];
properties[map.column.name] = {
[map.column.type]: getValue(map.column.type, value) || null,
};
@@ -425,130 +401,64 @@ const buildNotionPayloadProperties = (
// notion requires specific payload for each column type
// * TYPES NOT SUPPORTED BY NOTION API - rollup, created_by, created_time, last_edited_by, or last_edited_time
type TNotionValueInput = string | string[] | Date | number | Record<string, string> | undefined;
const coerceToNotionString = (
value: TNotionValueInput,
options?: { allowArrays?: boolean }
): string | null => {
if (value == null) {
return null;
}
if (Array.isArray(value)) {
if (!options?.allowArrays) {
return null;
}
return value.join("\n");
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "object") {
return JSON.stringify(value);
}
return String(value);
};
const getSelectValue = (value: TNotionValueInput) => {
if (typeof value !== "string" || value.length === 0) {
return null;
}
return {
name: value.replaceAll(",", ""),
};
};
const getMultiSelectValue = (value: TNotionValueInput) => {
if (!Array.isArray(value)) {
return null;
}
return (value as unknown[]).flatMap((entry) => {
if (typeof entry === "string") {
return [{ name: entry.replaceAll(",", "") }];
}
if (
entry &&
typeof entry === "object" &&
"name" in entry &&
typeof (entry as { name?: unknown }).name === "string"
) {
return [{ name: (entry as { name: string }).name.replaceAll(",", "") }];
}
return [];
});
};
const getTitleValue = (value: TNotionValueInput) => {
const content = coerceToNotionString(value, { allowArrays: true });
if (!content) {
return null;
}
return [
{
text: {
content,
},
},
];
};
const getRichTextValue = (value: TNotionValueInput) => {
const content = coerceToNotionString(value, { allowArrays: true });
if (!content) {
return null;
}
return [
{
text: {
content:
content.length > NOTION_RICH_TEXT_LIMIT ? truncateText(content, NOTION_RICH_TEXT_LIMIT) : content,
},
},
];
};
const getUrlValue = (value: TNotionValueInput) => {
if (Array.isArray(value)) {
if (value.length !== 1) {
return null;
}
return coerceToNotionString(value[0]);
}
const content = coerceToNotionString(value);
if (!content) {
return null;
}
if (typeof value === "string") {
return value;
}
return content;
};
const getValue = (colType: string, value: TNotionValueInput) => {
const getValue = (
colType: string,
value: string | string[] | Date | number | Record<string, string> | undefined
) => {
try {
switch (colType) {
case "select":
return getSelectValue(value);
if (!value) return null;
if (typeof value === "string") {
// Replace commas
const sanitizedValue = value.replace(/,/g, "");
return {
name: sanitizedValue,
};
}
case "multi_select":
return getMultiSelectValue(value);
if (Array.isArray(value)) {
return value.map((v: string) => ({ name: v.replace(/,/g, "") }));
}
case "title":
return getTitleValue(value);
return [
{
text: {
content: value,
},
},
];
case "rich_text":
return getRichTextValue(value);
if (typeof value === "string") {
return [
{
text: {
content:
value.length > NOTION_RICH_TEXT_LIMIT ? truncateText(value, NOTION_RICH_TEXT_LIMIT) : value,
},
},
];
}
if (Array.isArray(value)) {
const content = value.join("\n");
return [
{
text: {
content:
content.length > NOTION_RICH_TEXT_LIMIT
? truncateText(content, NOTION_RICH_TEXT_LIMIT)
: content,
},
},
];
}
return [
{
text: {
content: value,
},
},
];
case "status":
return {
name: value,
@@ -561,16 +471,12 @@ const getValue = (colType: string, value: TNotionValueInput) => {
};
case "email":
return value;
case "number": {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
case "number":
return parseInt(value as string);
case "phone_number":
return value;
case "url":
return getUrlValue(value);
default:
return null;
return typeof value === "string" ? value : (value as string[]).join(", ");
}
} catch (error) {
logger.error(error, "Payload build failed!");
@@ -4,6 +4,7 @@ import { v7 as uuidv7 } from "uuid";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
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";
@@ -21,12 +22,11 @@ import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
import { sendResponseFinishedEmail } from "@/modules/email";
import { handleIntegrations } from "@/modules/response-pipeline/lib/handle-integrations";
import { captureSurveyResponsePostHogEvent } from "@/modules/response-pipeline/lib/posthog";
import { sendTelemetryEvents } from "@/modules/response-pipeline/lib/telemetry";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
export const POST = async (request: Request) => {
const requestHeaders = await headers();
@@ -1,98 +0,0 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getResponseIdByDisplayId } from "./response";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs: [unknown, unknown][]) =>
inputs.map((input: [unknown, unknown]) => input[0])
),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
findFirst: vi.fn(),
},
},
}));
describe("getResponseIdByDisplayId", () => {
const workspaceId = "ws1234567890123456789012";
const displayId = "display1234567890123456789";
beforeEach(() => {
vi.clearAllMocks();
});
test("returns the linked responseId when a response exists", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: {
id: "response123456789012345678",
},
} as any);
const result = await getResponseIdByDisplayId(workspaceId, displayId);
expect(validateInputs).toHaveBeenCalledWith(
[workspaceId, expect.any(Object)],
[displayId, expect.any(Object)]
);
expect(prisma.display.findFirst).toHaveBeenCalledWith({
where: {
id: displayId,
survey: {
workspaceId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
expect(result).toEqual({ responseId: "response123456789012345678" });
});
test("returns null when the display exists but has no response", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: null,
} as any);
await expect(getResponseIdByDisplayId(workspaceId, displayId)).resolves.toEqual({
responseId: null,
});
});
test("throws ResourceNotFoundError when the display does not exist in the workspace", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue(null);
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(
new ResourceNotFoundError("Display", displayId)
);
});
test("throws ValidationError when input validation fails", async () => {
const validationError = new ValidationError("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(ValidationError);
expect(prisma.display.findFirst).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "test",
});
vi.mocked(prisma.display.findFirst).mockRejectedValue(prismaError);
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(DatabaseError);
});
});
@@ -1,44 +0,0 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getResponseIdByDisplayId = async (
workspaceId: string,
displayId: string
): Promise<{ responseId: string | null }> => {
validateInputs([workspaceId, ZId], [displayId, ZId]);
try {
const display = await prisma.display.findFirst({
where: {
id: displayId,
survey: {
workspaceId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
if (!display) {
throw new ResourceNotFoundError("Display", displayId);
}
return {
responseId: display.response?.id ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -1,49 +0,0 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { getResponseIdByDisplayId } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: THandlerParams<{ params: Promise<{ workspaceId: string; displayId: string }> }>) => {
const params = await props.params;
const resolved = await resolveClientApiIds(params.workspaceId);
if (!resolved) {
return {
response: responses.notFoundResponse("Workspace", params.workspaceId, true),
};
}
const { workspaceId } = resolved;
try {
const response = await getResponseIdByDisplayId(workspaceId, params.displayId);
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Display", params.displayId, true),
};
}
logger.error(
{ error, url: req.url, workspaceId, displayId: params.displayId },
"Error in GET /api/v1/client/[workspaceId]/displays/[displayId]/response"
);
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
},
});
@@ -33,13 +33,10 @@ vi.mock("@formbricks/logger", () => ({
vi.mock("./data");
vi.mock("@/app/lib/api/api-backwards-compat", () => ({
addLegacyProjectOverwritesToList: vi.fn((surveys: unknown[]) =>
surveys.map((survey) => {
const typedSurvey = survey as Record<string, unknown>;
return {
...typedSurvey,
projectOverwrites: typedSurvey.workspaceOverwrites ?? null,
};
})
surveys.map((s: Record<string, unknown>) => ({
...s,
projectOverwrites: s.workspaceOverwrites ?? null,
}))
),
addLegacyProjectToEnvironmentState: vi.fn((data: Record<string, unknown>) => ({
...data,
@@ -132,7 +129,7 @@ const mockActionClasses = [
description: null,
type: "code",
noCodeConfig: null,
workspaceId,
environmentId: workspaceId,
key: "action1",
},
] as unknown as TActionClass[];
@@ -126,6 +126,7 @@ export const POST = withV1ApiWrapper({
response: responses.badRequestResponse(
"Survey is part of another workspace",
{
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
+31 -22
View File
@@ -15,14 +15,20 @@ const apiKeySelect = {
lastUsedAt: true,
apiKeyWorkspaces: {
select: {
workspace: {
environment: {
select: {
id: true,
legacyEnvironmentId: true,
type: true,
createdAt: true,
updatedAt: true,
name: true,
workspaceId: true,
appSetupCompleted: true,
workspace: {
select: {
id: true,
name: true,
},
},
},
},
permission: true,
@@ -38,13 +44,17 @@ type ApiKeyData = {
lastUsedAt: Date | null;
apiKeyWorkspaces: Array<{
permission: string;
workspace: {
environment: {
id: string;
legacyEnvironmentId: string | null;
type: string;
createdAt: Date;
updatedAt: Date;
name: string;
workspaceId: string;
appSetupCompleted: boolean;
workspace: {
id: string;
name: string;
};
};
}>;
};
@@ -106,24 +116,21 @@ const updateApiKeyUsage = async (apiKeyId: string) => {
});
};
const buildWorkspaceResponse = (apiKeyData: ApiKeyData) => {
const workspace = apiKeyData.apiKeyWorkspaces[0].workspace;
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
const env = apiKeyData.apiKeyWorkspaces[0].environment;
return Response.json({
// Keep v1 payload shape stable while sourcing data from workspace.
id: workspace.legacyEnvironmentId ?? workspace.id,
type: "production",
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
appSetupCompleted: workspace.appSetupCompleted,
id: env.id,
type: env.type,
createdAt: env.createdAt,
updatedAt: env.updatedAt,
appSetupCompleted: env.appSetupCompleted,
workspace: {
id: workspace.id,
name: workspace.name,
id: env.workspaceId,
name: env.workspace.name,
},
// Backwards compat: old consumers expect project fields
project: {
id: workspace.id,
name: workspace.name,
},
projectId: env.workspaceId,
projectName: env.workspace.name,
});
};
@@ -150,12 +157,14 @@ const handleApiKeyAuthentication = async (apiKey: string) => {
});
}
// Rate limiting for apiKey auth is enforced by Envoy in v5 — see envoy-rate-limit-coverage.ts
const rateLimitError = await checkRateLimit(apiKeyData.id);
if (rateLimitError) return rateLimitError;
if (!isValidApiKeyEnvironment(apiKeyData)) {
return responses.badRequestResponse("You can't use this method with this API key");
}
return buildWorkspaceResponse(apiKeyData);
return buildEnvironmentResponse(apiKeyData);
};
const handleSessionAuthentication = async () => {
@@ -73,6 +73,7 @@ const validateSurvey = async (responseInput: TResponseInput, workspaceId: string
error: responses.badRequestResponse(
"Survey is part of another workspace",
{
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
@@ -1,16 +1,43 @@
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 { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey } from "./surveys";
const { mockDeleteSharedSurvey } = vi.hoisted(() => ({
mockDeleteSharedSurvey: vi.fn(),
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: mockDeleteSharedSurvey,
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
delete: vi.fn(),
},
segment: {
delete: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
const workspaceId = "clq5n7p1q0000m7z0h5p6g3r3";
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
const mockDeletedSurveyAppPrivateSegment = {
id: surveyId,
workspaceId,
type: "app",
segment: { id: segmentId, isPrivate: true },
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
};
const mockDeletedSurveyLink = {
id: surveyId,
@@ -29,20 +56,66 @@ describe("deleteSurvey", () => {
vi.clearAllMocks();
});
test("delegates survey deletion to the shared service", async () => {
mockDeleteSharedSurvey.mockResolvedValue(mockDeletedSurveyLink);
test("should delete a link survey without a segment and revalidate caches", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any);
const deletedSurvey = await deleteSurvey(surveyId);
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
include: {
segment: true,
triggers: { include: { actionClass: true } },
},
});
expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
});
test("rethrows shared delete service errors", async () => {
test("should handle PrismaClientKnownRequestError during survey deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError during segment deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Foreign key constraint failed", {
code: "P2003",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyAppPrivateSegment as any);
vi.mocked(prisma.segment.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } });
});
test("should handle generic errors during deletion", async () => {
const genericError = new Error("Something went wrong");
mockDeleteSharedSurvey.mockRejectedValue(genericError);
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
expect(logger.error).not.toHaveBeenCalled();
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should throw validation error for invalid surveyId", async () => {
const invalidSurveyId = "invalid-id";
const validationError = new Error("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
expect(prisma.survey.delete).not.toHaveBeenCalled();
});
});
@@ -1,3 +1,43 @@
import { deleteSurvey as deleteSharedSurvey } from "@/modules/survey/lib/surveys";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const deleteSurvey = async (surveyId: string) => deleteSharedSurvey(surveyId);
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, z.cuid2()]);
try {
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
include: {
segment: true,
triggers: {
include: {
actionClass: true,
},
},
},
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await prisma.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
}
return deletedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error({ error, surveyId }, "Error deleting survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -1,6 +1,5 @@
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
@@ -79,12 +78,6 @@ export const GET = withV1ApiWrapper({
),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Survey", params.surveyId),
};
}
return {
response: handleErrorResponse(error),
};
@@ -0,0 +1,141 @@
import * as Sentry from "@sentry/nextjs";
import { type NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
applyIPRateLimit: vi.fn(),
getWorkspaceState: vi.fn(),
resolveClientApiIds: vi.fn(),
contextualLoggerError: vi.fn(),
}));
vi.mock("@/app/api/v1/client/[workspaceId]/environment/lib/environmentState", () => ({
getWorkspaceState: mocks.getWorkspaceState,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: mocks.applyIPRateLimit,
applyRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
},
},
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.contextualLoggerError,
warn: vi.fn(),
info: vi.fn(),
})),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
SENTRY_DSN: "test-dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
};
});
const createMockRequest = (url: string, headers = new Map<string, string>()): NextRequest => {
const parsedUrl = new URL(url);
return {
method: "GET",
url,
headers: {
get: (key: string) => headers.get(key),
},
nextUrl: {
pathname: parsedUrl.pathname,
},
} as unknown as NextRequest;
};
describe("api/v2 client environment route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyIPRateLimit.mockResolvedValue(undefined);
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId: "ck12345678901234567890123" });
});
test("reports v1-backed failures as v2 and keeps the response payload unchanged", async () => {
const underlyingError = new Error("Environment load failed");
mocks.getWorkspaceState.mockRejectedValue(underlyingError);
const request = createMockRequest(
"https://api.test/api/v2/client/ck12345678901234567890123/environment",
new Map([["x-request-id", "req-v2-env"]])
);
const { GET } = await import("../../../../v1/client/[workspaceId]/environment/route");
const response = await GET(request, {
params: Promise.resolve({
workspaceId: "ck12345678901234567890123",
}),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "An error occurred while processing your request.",
details: {},
});
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
underlyingError,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
originalError: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
status: 500,
}),
}),
})
);
});
});
@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createDisplay: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromWorkspaceId: vi.fn(),
reportApiError: vi.fn(),
resolveClientApiIds: vi.fn(),
}));
vi.mock("./lib/display", () => ({
createDisplay: mocks.createDisplay,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: mocks.getOrganizationIdFromWorkspaceId,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client displays route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId: environmentId });
mocks.getOrganizationIdFromWorkspaceId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
});
test("returns a v2 bad request response for malformed JSON without reporting an internal error", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "{",
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual(
expect.objectContaining({
code: "bad_request",
message: "Invalid JSON in request body",
})
);
expect(mocks.createDisplay).not.toHaveBeenCalled();
expect(mocks.reportApiError).not.toHaveBeenCalled();
});
test("reports unexpected createDisplay failures while keeping the response payload unchanged", async () => {
const underlyingError = new Error("display persistence failed");
mocks.createDisplay.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
});
test("reports unexpected contact-license lookup failures with the same generic public response", async () => {
const underlyingError = new Error("license lookup failed");
mocks.getOrganizationIdFromWorkspaceId.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
contactId: "clh123456789012345678901234",
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createDisplay).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -178,33 +178,9 @@ describe("createResponse V2", () => {
).rejects.toThrow(ResourceNotFoundError);
});
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["surveyId", "singleUseId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
code: "P2002",
clientVersion: "test",
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
@@ -2,7 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -131,13 +131,6 @@ export const createResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
const target = (error.meta?.target as string[]) ?? [];
if (target?.includes("singleUseId")) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
}
throw new DatabaseError(error.message);
}
@@ -92,7 +92,6 @@ const mockSurvey: TSurvey = {
isCaptureIpEnabled: false,
metadata: {},
slug: null,
isAutoProgressingEnabled: true,
};
const mockResponseInput: TResponseInputV2 = {
@@ -127,6 +126,7 @@ describe("checkSurveyValidity", () => {
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Survey is part of another workspace",
{
"survey.workspaceId": "ws-2",
workspaceId: "ws-1",
},
true
@@ -20,6 +20,7 @@ export const checkSurveyValidity = async (
return responses.badRequestResponse(
"Survey is part of another workspace",
{
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
checkSurveyValidity: vi.fn(),
createResponseWithQuotaEvaluation: vi.fn(),
getClientIpFromHeaders: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromWorkspaceId: vi.fn(),
getSurvey: vi.fn(),
reportApiError: vi.fn(),
resolveClientApiIds: vi.fn(),
sendToPipeline: vi.fn(),
validateResponseData: vi.fn(),
}));
vi.mock("@/app/api/v2/client/[workspaceId]/responses/lib/utils", () => ({
checkSurveyValidity: mocks.checkSurveyValidity,
}));
vi.mock("./lib/response", () => ({
createResponseWithQuotaEvaluation: mocks.createResponseWithQuotaEvaluation,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mocks.sendToPipeline,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: mocks.getClientIpFromHeaders,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: mocks.getOrganizationIdFromWorkspaceId,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
validateResponseData: mocks.validateResponseData,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client responses route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId: environmentId });
mocks.checkSurveyValidity.mockResolvedValue(null);
mocks.getSurvey.mockResolvedValue({
id: surveyId,
environmentId,
blocks: [],
questions: [],
isCaptureIpEnabled: false,
});
mocks.validateResponseData.mockReturnValue(null);
mocks.getOrganizationIdFromWorkspaceId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
mocks.getClientIpFromHeaders.mockResolvedValue("127.0.0.1");
});
test("reports unexpected response creation failures while keeping the public payload generic", async () => {
const underlyingError = new Error("response persistence failed");
mocks.createResponseWithQuotaEvaluation.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
test("reports unexpected pre-persistence failures with the same generic public response", async () => {
const underlyingError = new Error("survey lookup failed");
mocks.getSurvey.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response-pre-check",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createResponseWithQuotaEvaluation).not.toHaveBeenCalled();
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,5 @@
import { UAParser } from "ua-parser-js";
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[workspaceId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
@@ -164,10 +164,6 @@ const createResponseForRequest = async ({
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message, undefined, true);
}
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,
-132
View File
@@ -9,22 +9,6 @@ const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: mockGetServerSession,
}));
@@ -41,14 +25,6 @@ vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
@@ -69,114 +45,6 @@ describe("withV3ApiWrapper", () => {
vi.clearAllMocks();
});
test("passes an audit log to the handler and queues success after the response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1", name: "Test", email: "t@example.com" },
expires: "2026-01-01",
});
const handler = vi.fn(async ({ auditLog }) => {
expect(auditLog).toEqual(
expect.objectContaining({
action: "deleted",
targetType: "survey",
userId: "user_1",
userType: "user",
status: "failure",
})
);
if (auditLog) {
auditLog.targetId = "survey_1";
auditLog.organizationId = "org_1";
auditLog.oldObject = { id: "survey_1" };
}
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_1", {
method: "DELETE",
headers: { "x-request-id": "req-audit" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_1",
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: { id: "survey_1" },
})
);
});
test("queues a failure audit log when the handler returns a non-ok response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockAuthenticateRequest.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
environmentPermissions: [],
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler: async ({ auditLog }) => {
if (auditLog) {
auditLog.targetId = "survey_2";
}
return new Response("forbidden", { status: 403 });
},
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_2", {
method: "DELETE",
headers: {
"x-request-id": "req-failure-audit",
"x-api-key": "fbk_test",
},
}),
{} as never
);
expect(response.status).toBe(403);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_2",
organizationId: "org_1",
userId: "key_1",
userType: "api",
status: "failure",
eventId: "req-failure-audit",
})
);
});
test("uses session auth first in both mode and injects request id into plain responses", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
+2 -76
View File
@@ -4,13 +4,10 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
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 type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
import {
type InvalidParam,
problemBadRequest,
@@ -18,7 +15,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3AuditLog, TV3Authentication } from "./types";
import type { TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
@@ -41,7 +38,6 @@ export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unkn
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
auditLog?: TV3AuditLog;
parsedInput: TParsedInput;
requestId: string;
instance: string;
@@ -52,8 +48,6 @@ export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = u
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
action?: TAuditAction;
targetType?: TAuditTarget;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
@@ -299,61 +293,10 @@ async function applyV3RateLimitOrRespond(params: {
return null;
}
function buildV3AuditLog(
authentication: TV3Authentication,
action?: TAuditAction,
targetType?: TAuditTarget,
apiUrl?: string
): TV3AuditLog | undefined {
if (!authentication || !action || !targetType || !apiUrl) {
return undefined;
}
const auditLog = buildAuditLogBaseObject(action, targetType, apiUrl);
if ("user" in authentication && authentication.user?.id) {
auditLog.userId = authentication.user.id;
auditLog.userType = "user";
} else if ("apiKeyId" in authentication) {
auditLog.userId = authentication.apiKeyId;
auditLog.userType = "api";
auditLog.organizationId = authentication.organizationId;
}
return auditLog;
}
async function queueV3AuditLog(
auditLog: TV3AuditLog | undefined,
requestId: string,
log: ReturnType<typeof logger.withContext>
): Promise<void> {
if (!auditLog) {
return;
}
try {
await queueAuditEvent({
...auditLog,
...(auditLog.status === "failure" ? { eventId: auditLog.eventId ?? requestId } : {}),
});
} catch (error) {
log.error({ error }, "Failed to queue V3 audit event");
}
}
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const {
auth = "both",
schemas,
rateLimit = true,
customRateLimitConfig,
handler,
action,
targetType,
} = params;
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
@@ -363,7 +306,6 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
method: req.method,
path: instance,
});
let auditLog: TV3AuditLog | undefined;
try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
@@ -389,33 +331,17 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
return rateLimitResponse;
}
auditLog = buildV3AuditLog(authResult.authentication, action, targetType, req.url);
const response = await handler({
req,
props,
authentication: authResult.authentication,
auditLog,
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
});
if (auditLog) {
if (response.ok) {
auditLog.status = "success";
} else {
auditLog.eventId = requestId;
}
}
await queueV3AuditLog(auditLog, requestId, log);
return ensureRequestIdHeader(response, requestId);
} catch (error) {
if (auditLog) {
auditLog.eventId = requestId;
await queueV3AuditLog(auditLog, requestId, log);
}
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
-25
View File
@@ -7,7 +7,6 @@ import {
problemTooManyRequests,
problemUnauthorized,
successListResponse,
successResponse,
} from "./response";
describe("v3 problem responses", () => {
@@ -94,27 +93,3 @@ describe("successListResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});
describe("successResponse", () => {
test("wraps the payload in a data envelope", async () => {
const res = successResponse({ id: "survey_1" }, { requestId: "req-success" });
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-success");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: { id: "survey_1" },
});
});
test("allows custom status and cache headers", async () => {
const res = successResponse(
{ ok: true },
{
cache: "private, max-age=60",
status: 202,
}
);
expect(res.status).toBe(202);
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
-24
View File
@@ -147,27 +147,3 @@ export function successListResponse<T, TMeta extends Record<string, unknown>>(
}
return Response.json({ data, meta }, { status: 200, headers });
}
export function successResponse<T>(
data: T,
options?: { requestId?: string; cache?: string; status?: number }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json(
{
data,
},
{
status: options?.status ?? 200,
headers,
}
);
}
-2
View File
@@ -1,6 +1,4 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import type { TApiAuditLog } from "@/app/lib/api/with-api-logging";
export type TV3Authentication = TAuthenticationApiKey | Session | null;
export type TV3AuditLog = TApiAuditLog;
@@ -1,318 +0,0 @@
import { ApiKeyPermission } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const workspaceId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) {
headers["x-request-id"] = requestId;
}
return new NextRequest(url, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
workspacePermissions: [
{
workspaceId,
workspaceName: "W",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
workspaceId: workspaceId,
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
workspaceId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
workspaceId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
});
@@ -1,72 +0,0 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: z.object({
surveyId: z.cuid2(),
}),
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const survey = await getSurvey(surveyId);
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
if (auditLog) {
auditLog.targetId = survey.id;
auditLog.organizationId = authResult.organizationId;
auditLog.oldObject = survey;
}
const deletedSurvey = await deleteSurvey(surveyId);
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey delete unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
+1 -1
View File
@@ -314,8 +314,8 @@ describe("GET /api/v3/surveys", () => {
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("ws_1");
});
@@ -93,17 +93,17 @@ describe("parseAndValidateJsonBody", () => {
request,
schema: z.object({
finished: z.boolean(),
workspaceId: z.string(),
environmentId: z.string(),
}),
buildInput: (jsonInput) => ({
...(jsonInput as Record<string, unknown>),
workspaceId: "ws_123",
environmentId: "env_123",
}),
});
expect(result).toEqual({
data: {
workspaceId: "ws_123",
environmentId: "env_123",
finished: true,
},
});
-50
View File
@@ -339,56 +339,6 @@ describe("API Response Utilities", () => {
});
});
describe("conflictResponse", () => {
test("should return a conflict response", () => {
const message = "Resource already exists";
const details = { field: "singleUseId" };
const response = responses.conflictResponse(message, details);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details: {},
});
});
});
test("should include CORS headers when cors is true", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message, undefined, true);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
});
test("should use custom cache control header when provided", () => {
const message = "Resource already exists";
const customCache = "no-cache";
const response = responses.conflictResponse(message, undefined, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("tooManyRequestsResponse", () => {
test("should return a too many requests response", () => {
const message = "Rate limit exceeded";
+1 -27
View File
@@ -16,8 +16,7 @@ interface ApiErrorResponse {
| "method_not_allowed"
| "not_authenticated"
| "forbidden"
| "too_many_requests"
| "conflict";
| "too_many_requests";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -237,30 +236,6 @@ const internalServerErrorResponse = (
);
};
const conflictResponse = (
message: string,
details?: { [key: string]: string },
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "conflict",
message,
details: details || {},
} as ApiErrorResponse,
{
status: 409,
headers,
}
);
};
const tooManyRequestsResponse = (
message: string,
cors: boolean = false,
@@ -295,5 +270,4 @@ export const responses = {
successResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
};
+19 -140
View File
@@ -3,16 +3,9 @@ import { NextRequest } from "next/server";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import type { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { responses } from "./response";
const AuthMethod = {
ApiKey: "apiKey" as AuthenticationMethod,
Session: "session" as AuthenticationMethod,
Both: "both" as AuthenticationMethod,
None: "none" as AuthenticationMethod,
} as const;
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
__esModule: true,
queueAuditEvent: vi.fn(),
@@ -129,7 +122,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.ApiKey,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -205,7 +198,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.ApiKey,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -251,7 +244,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.ApiKey,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -325,7 +318,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.ApiKey,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -377,7 +370,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.ApiKey,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -432,7 +425,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.ApiKey,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -456,7 +449,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthMethod.None,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
@@ -480,90 +473,6 @@ describe("withV1ApiWrapper", () => {
});
});
test("skips app rate limiting for Envoy-covered client routes", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ method: "POST", url: "/api/v1/client/env_123/storage" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyIPRateLimit).not.toHaveBeenCalled();
});
test("keeps app rate limiting for uncovered client routes", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ method: "GET", url: "/api/v2/client/env_123/environment" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("keeps app rate limiting for uncovered verbs on otherwise covered client paths", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ method: "PATCH", url: "/api/v1/client/env_123/environment" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
@@ -572,7 +481,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.ApiKey,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
@@ -595,7 +504,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.Session,
authenticationMethod: AuthenticationMethod.Session,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue(null);
@@ -619,36 +528,7 @@ describe("withV1ApiWrapper", () => {
expect(mockContextualLoggerError).toHaveBeenCalled();
});
test("keeps app rate limiting for uncovered session-authenticated management routes", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.Both,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } } as any);
const rateLimitError = new Error("Rate limit exceeded");
rateLimitError.message = "Rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ method: "POST", url: "https://api.test/api/v1/management/storage" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const customRateLimitConfig = { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" };
const wrapped = withV1ApiWrapper({ handler, customRateLimitConfig });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
expect(applyRateLimit).toHaveBeenCalledWith(customRateLimitConfig, "user-1");
});
test("skips app rate limiting for Envoy-covered API-key management routes", async () => {
test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
@@ -658,22 +538,21 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.ApiKey,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(applyRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const rateLimitError = new Error("Rate limit exceeded");
rateLimitError.message = "Rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyRateLimit).not.toHaveBeenCalled();
expect(res.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
});
test("skips audit log creation when no action/targetType provided", async () => {
@@ -687,7 +566,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.ApiKey,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
+6 -44
View File
@@ -13,10 +13,6 @@ import {
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
TEnvoyRateLimitAuthType,
isRouteRateLimitedByEnvoy,
} from "@/modules/core/rate-limit/envoy-rate-limit-coverage";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
@@ -65,58 +61,29 @@ const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): P
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
};
const getEnvoyRateLimitAuthType = (
authentication: TApiV1Authentication
): TEnvoyRateLimitAuthType | "unknown" => {
if (!authentication) {
return "none";
}
if ("user" in authentication) {
return "session";
}
if ("apiKeyId" in authentication) {
return "apiKey";
}
return "unknown";
};
/**
* Handle rate limiting based on authentication and API type
*/
const handleRateLimiting = async (
req: NextRequest,
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum,
customRateLimitConfig?: TRateLimitConfig
): Promise<Response | null> => {
const authType = getEnvoyRateLimitAuthType(authentication);
if (authType === "unknown") {
logger.error({ authentication }, "Unknown authentication type");
return responses.internalServerErrorResponse("Invalid authentication configuration");
}
const isEnvoyManagedRateLimit = isRouteRateLimitedByEnvoy({
pathname: req.nextUrl.pathname,
method: req.method,
authType,
});
try {
if (authentication && !isEnvoyManagedRateLimit) {
if (authentication) {
if ("user" in authentication) {
// Session-based authentication for integration routes
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.user.id);
} else if ("apiKeyId" in authentication) {
// API key authentication for general routes
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.apiKeyId);
} else {
logger.error({ authentication }, "Unknown authentication type");
return responses.internalServerErrorResponse("Invalid authentication configuration");
}
}
if (routeType === ApiV1RouteTypeEnum.Client && !isEnvoyManagedRateLimit) {
if (routeType === ApiV1RouteTypeEnum.Client) {
await applyClientRateLimit(customRateLimitConfig);
}
} catch (error) {
@@ -319,12 +286,7 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(
req,
authentication,
routeType,
customRateLimitConfig
);
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
if (rateLimitResponse) return rateLimitResponse;
}
+15 -64
View File
@@ -3,9 +3,7 @@ import type { TFunction } from "i18next";
import type { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import type {
TSurveyCTAElement,
TSurveyCesElement,
TSurveyConsentElement,
TSurveyCsatElement,
TSurveyElement,
TSurveyMultipleChoiceElement,
TSurveyNPSElement,
@@ -98,8 +96,7 @@ export const buildOpenTextElement = ({
};
};
const buildScaleElement = <T extends TSurveyRatingElement | TSurveyCsatElement | TSurveyCesElement>({
type,
export const buildRatingElement = ({
id,
headline,
subheader,
@@ -110,32 +107,6 @@ const buildScaleElement = <T extends TSurveyRatingElement | TSurveyCsatElement |
required,
isColorCodingEnabled = false,
}: {
type: T["type"];
id?: string;
headline: string;
scale: T["scale"];
range: T["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): T => {
return {
id: id ?? createId(),
type,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
} as T;
};
export const buildRatingElement = (params: {
id?: string;
headline: string;
scale: TSurveyRatingElement["scale"];
@@ -145,8 +116,20 @@ export const buildRatingElement = (params: {
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyRatingElement =>
buildScaleElement<TSurveyRatingElement>({ ...params, type: TSurveyElementTypeEnum.Rating });
}): TSurveyRatingElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.Rating,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
};
};
export const buildConsentElement = ({
id,
@@ -229,38 +212,6 @@ export const buildNPSElement = ({
};
};
export const buildCsatElement = ({
scale = "smiley",
...params
}: {
id?: string;
headline: string;
scale?: TSurveyCsatElement["scale"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyCsatElement =>
buildScaleElement<TSurveyCsatElement>({ ...params, scale, range: 5, type: TSurveyElementTypeEnum.CSAT });
export const buildCesElement = ({
scale = "number",
range = 5,
...params
}: {
id?: string;
headline: string;
scale?: TSurveyCesElement["scale"];
range?: TSurveyCesElement["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyCesElement =>
buildScaleElement<TSurveyCesElement>({ ...params, scale, range, type: TSurveyElementTypeEnum.CES });
// Helper function to create block-level jump logic based on operator
export const createBlockJumpLogic = (
sourceElementId: string,
-6
View File
@@ -30,8 +30,6 @@ const conditionOptions: Record<string, string[]> = {
multipleChoiceMulti: ["Includes all", "Includes either"],
nps: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped", "Includes either"],
rating: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
csat: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
ces: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
cta: ["is"],
tags: ["is"],
languages: ["Equals", "Not equals"],
@@ -47,8 +45,6 @@ const filterOptions: Record<string, string[]> = {
openText: ["Filled out", "Skipped"],
rating: ["1", "2", "3", "4", "5"],
nps: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
csat: ["1", "2", "3", "4", "5"],
ces: ["1", "2", "3", "4", "5", "6", "7"],
cta: ["Clicked", "Dismissed"],
tags: ["Applied", "Not applied"],
consent: ["Accepted", "Dismissed"],
@@ -440,8 +436,6 @@ const processElementFilters = (
break;
case TSurveyElementTypeEnum.NPS:
case TSurveyElementTypeEnum.Rating:
case TSurveyElementTypeEnum.CSAT:
case TSurveyElementTypeEnum.CES:
processNPSRatingFilter(filterType, elementId, filters);
break;
case TSurveyElementTypeEnum.CTA:
+14 -15
View File
@@ -7,9 +7,7 @@ import type { TTemplate } from "@formbricks/types/templates";
import {
buildBlock,
buildCTAElement,
buildCesElement,
buildConsentElement,
buildCsatElement,
buildMultipleChoiceElement,
buildNPSElement,
buildOpenTextElement,
@@ -973,13 +971,13 @@ const improveTrialConversion = (t: TFunction): TTemplate => {
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.improve_trial_conversion_question_3_headline"),
headline: t("templates.improve_trial_conversion_question_2_headline"),
required: true,
inputType: "text",
}),
],
logic: [createBlockJumpLogic(reusableElementIds[2], block6Id, "isSubmitted")],
buttonLabel: t("templates.improve_trial_conversion_question_3_button_label"),
buttonLabel: t("templates.improve_trial_conversion_question_2_button_label"),
t,
}),
buildBlock({
@@ -1321,7 +1319,8 @@ const employeeSatisfaction = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_1"),
elements: [
buildCsatElement({
buildRatingElement({
range: 5,
scale: "star",
headline: t("templates.employee_satisfaction_question_1_headline"),
required: true,
@@ -1648,14 +1647,14 @@ const identifyCustomerGoals = (t: TFunction): TTemplate => {
elements: [
buildMultipleChoiceElement({
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: t("templates.identify_customer_goals_question_1_headline"),
headline: "What's your primary goal for using $[workspaceName]?",
required: true,
shuffleOption: "none",
choices: [
t("templates.identify_customer_goals_question_1_choice_1"),
t("templates.identify_customer_goals_question_1_choice_2"),
t("templates.identify_customer_goals_question_1_choice_3"),
t("templates.identify_customer_goals_question_1_choice_4"),
"Understand my user base deeply",
"Identify upselling opportunities",
"Build the best possible product",
"Rule the world to make everyone breakfast brussels sprouts.",
],
}),
],
@@ -2724,7 +2723,7 @@ const customerEffortScore = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_1"),
elements: [
buildCesElement({
buildRatingElement({
range: 5,
scale: "number",
headline: t("templates.customer_effort_score_question_1_headline"),
@@ -3829,8 +3828,9 @@ const improveNewsletterContent = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_1"),
elements: [
buildCsatElement({
buildRatingElement({
id: reusableElementIds[0],
range: 5,
scale: "smiley",
headline: t("templates.improve_newsletter_content_question_1_headline"),
required: true,
@@ -4409,7 +4409,8 @@ const longTermRetentionCheckIn = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_9"),
elements: [
buildCsatElement({
buildRatingElement({
range: 5,
scale: "smiley",
headline: t("templates.long_term_retention_check_in_question_9_headline"),
required: true,
@@ -4824,8 +4825,6 @@ export const previewSurvey = (workspaceName: string, t: TFunction): TSurvey => {
workspaceId: "cmnh38nzx00003b6r3svd9pv2",
createdBy: "cltwumfbz0000echxysz6ptvq",
status: "inProgress" as const,
publishOn: null,
closeOn: null,
welcomeCard: {
enabled: false,
headline: createI18nString(t("templates.preview_survey_welcome_card_headline"), []),
@@ -121,10 +121,13 @@ export const DELETE = async (
: responses.notAuthenticatedResponse();
}
// Rate limiting for apiKey DELETE is enforced by Envoy in v5 — see envoy-rate-limit-coverage.ts
if (authResult.ok && authResult.data.authType !== "apiKey") {
if (authResult.ok) {
try {
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
if (authResult.data.authType === "apiKey") {
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.apiKeyId);
} else {
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
}
} catch (error) {
return responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Unknown error occurred"
@@ -139,20 +142,20 @@ export const DELETE = async (
idParam
);
if (!deleteResult.ok) {
const { error } = deleteResult;
const isSuccess = deleteResult.ok;
logger.error({ error }, "Error deleting file");
if (!isSuccess) {
logger.error({ error: deleteResult.error }, "Error deleting file");
await logFileDeletion({
failureReason: error.code,
failureReason: deleteResult.error.code,
accessType,
userId: session?.user?.id,
workspaceId: resolved.workspaceId,
apiUrl: request.url,
});
const errorResponse = getErrorResponseFromStorageError(error, { fileName });
const errorResponse = getErrorResponseFromStorageError(deleteResult.error, { fileName });
return errorResponse;
}
-1
View File
@@ -19,7 +19,6 @@
"ro-RO",
"ru-RU",
"sv-SE",
"tr-TR",
"zh-Hans-CN",
"zh-Hant-TW"
]
+54 -325
View File
@@ -98,9 +98,6 @@ checksums:
common/activity: 1948763de8e531483a798b68195e297e
common/add: 87c4a663507f2bcbbf79934af8164e13
common/add_action: 66fefc4dd6a7b939c2224272cf0d2669
common/add_chart: 0c8539d3ccc83fce87bb1e0dc3e30005
common/add_charts: c377a42e165e8ab67bfbb8ad72026dd8
common/add_existing_chart_description: b1292a1d6df2e03ad7b399689312c37f
common/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
common/add_logo: c8665aa9afd0d5a13528bdc96daefa53
common/add_member: 11979625770516ca287e929381778e02
@@ -112,7 +109,6 @@ checksums:
common/allow: 3e39cc5940255e6bff0fea95c817dd43
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
common/and: dc75b95c804b16dc617a5f16f7393bca
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
common/api_keys: f961b547cd312cc8b9b79f0c9e0b2cc3
@@ -131,9 +127,6 @@ checksums:
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
common/change_organization: 3b2c873962509445ff2cb8cde5ad913b
common/change_workspace: 489cbcf7eef9b9b960e426fbf4da318f
common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
common/charts: 1da4564d89264c89de4ed28d7451b43e
common/choice_n: a6965b8fb3e479e94175b3826839d9ae
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
common/choose_workspace: f9ed22d76c69cc75aa56cf3da3fa6320
@@ -146,9 +139,8 @@ checksums:
common/close: 2c2e22f8424a1031de89063bd0022e16
common/code: 343bc5386149b97cece2b093c39034b2
common/collapse_rows: 24988527f9180f37aa55d2aa183ccb21
common/column_n: b98315f0e504fad7e784d77f153a7d9d
common/completed: 0e4bbce9985f25eb673d9a054c8d5334
common/configuration: e3ab18ebb36c218cd4897c620f5809ac
common/configuration: 923ec0502721489202f6222dd4107163
common/confirm: 90930b51154032f119fa75c1bd422d8b
common/connect: 8778ee245078a8be4a2ce855c8c56edc
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
@@ -177,8 +169,6 @@ checksums:
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
common/dashboard: c9380ea68c8c76ea451bd9613329a07c
common/dashboards: 4bc47e48559a6b688684dcb7ac4babc9
common/date: 56f41c5d30a76295bb087b20b7bee4c3
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
@@ -206,6 +196,7 @@ checksums:
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
common/environment: 0844e8dc1485339c8de066dc0a9bb6a1
common/error: 3c95bcb32c2104b99a46f5b3dd015248
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
common/error_component_title: ae68fa341a143aaa13a5ea30dd57a63e
@@ -217,7 +208,6 @@ checksums:
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
common/failed_to_parse_csv: 7a3d675ecbb3d15884faf1006a5752d6
common/field_placeholder: 1fedb1aab1a4d42ad49ddece6d8df372
common/filter: 626325a05e4c8800f7ede7012b0cadaf
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/first_name: cf040a5d6a9fd696be400380cc99f54b
@@ -229,13 +219,10 @@ checksums:
common/generate: 0345bf322c191e70d01fd6607ec5c2f8
common/go_back: b917ea82facb90c88c523b255d29f84b
common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2
common/headline: 0023cbe059bbadcc77312825cbbce5ac
common/hidden: fa290c6ada5869d744ed35e9cca64699
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
common/hide: a6088b934651055bb27314d111be510b
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
common/html: f750870203043349d570d8f5865ca0f8
common/id: c8886d38aeea2ed5f785aba4fc96784b
common/image: 048ba7a239de0fbd883ade8558415830
common/images: 9305827c28694866f49db42b4c51831f
@@ -281,9 +268,9 @@ checksums:
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
common/mobile_overlay_title: 42f52259b7527989fb3a3240f5352a8b
common/months: da74749fbe80394fa0f72973d7b0964a
common/more_options: 53d90eae6a9b0243b5bc043b3d9de169
common/move_down: 4f4de55743043355ad4a839aff2c48ff
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
common/my_product: ad022177062f9ef6e9acf33b13e889aa
common/name: 9368b5a047572b6051f334af5aa76819
common/new: 126d036fae5fb6b629728ecb97e6195b
@@ -292,7 +279,6 @@ checksums:
common/no: 8c708225830b06df2d1141c536f2a0d6
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
common/no_changes: 17709e3e2fbd133ddb8b3291d13de7f6
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
common/no_overlay: 03cde9e91f08e4dd539d788e1e01407f
@@ -300,12 +286,10 @@ checksums:
common/no_result_found: fedddbc0149972ea072a9e063198a16d
common/no_results: 0e9b73265c6542240f5a3bf6b43e9280
common/no_surveys_found: 7b74706fe4f4aacd7d858e19e444fe85
common/no_text_found: 27350f35bdd57b3701c7ec578a1a0e11
common/none_of_the_above: e007f0b1e046d5ddbbcfbd87940456ee
common/not_authenticated: fed6c62208524ea6782b5f9c07a95a4f
common/not_authorized: 4be80383fe1a6f52c61138f1aa8d01d4
common/not_connected: 91ebf07fff6b2ead94d85bd17212e0ba
common/not_set: 380482630d60ee2d1531b31246caa467
common/note: e0337f202c911423275f834edeffc54b
common/notifications: c52df856139b50dbb1cae7bfb1cf73bb
common/number: 2789f8391f63e7200a5521078aab017d
@@ -316,7 +300,6 @@ checksums:
common/on: 1929bcf2fba8003c043b446a851bcb4f
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
common/open_options: a4578c0afbfdf4a76d5952a53085b72a
common/option_id: ed21d97b8ab035ba89fb3f5f073229bd
common/option_ids: e68c25215ce81ea7ad82ff7be0a0bf2d
common/optional: 396fb9a0472daf401c392bdc3e248943
@@ -326,7 +309,7 @@ checksums:
common/organization_settings: 11528aa89ae9935e55dcb54478058775
common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/other_placeholder: f3a0fa2eaaf75aa92b290449c928c081
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
common/password: 223a61cf906ab9c40d22612c588dff48
@@ -344,8 +327,10 @@ checksums:
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08
common/product_manager: dfeadc96e6d3de22a884ee97974b505e
common/production: 226e0ce83b49700bc1b1c08c4c3ed23a
common/profile: d7878693f91303a438852d617f6d35df
common/profile_id: 0ef1286cce9d47b148e9a09deccb6655
common/progress: dd0200d5849ebb7d64c15098ae91d229
@@ -357,7 +342,6 @@ checksums:
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
common/read_docs: d06513c266fdd9056e0500eab838ebac
common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/refresh: c0aec3f31be4c984bae9a482572d2857
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
@@ -365,24 +349,19 @@ checksums:
common/report_survey: 147dd05db52e35f5d1f837460fb720f5
common/request_trial_license: 560df1240ef621f7c60d3f7d65422ccd
common/reset_to_default: 68ee98b46677392f44b505b268053b26
common/resize: 20887e5af5294f08bc72cdedeee6e7a8
common/response: c7a9d88269d8ff117abcbc0d97f88b2c
common/response_id: 73375099cc976dc7203b8e27f5f709e0
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
common/restart: bab6232e89f24e3129f8e48268739d5b
common/retry: 6e44d18639560596569a1278f9c83676
common/role: 53743bbb6ca938f5b893552e839d067f
common/row_n: f90f7018a69f2d7025ad99a90bd23dc9
common/saas: f01686245bcfb35a3590ab56db677bdb
common/sales: 38758eb50094cd8190a71fe67be4d647
common/save: f7a2929f33bc420195e59ac5a8bcd454
common/save_as_draft: b1b38812110113627d141db981fb1b12
common/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
common/save_without_scheduling: c6595873d611e6d1786fb15281158467
common/saving: 27ad05746d65e2f3f17d327eb181725d
common/scheduled: 1283929a2810dcf6110765f387dc118e
common/search: 49dd6c21604b5e8d4153ff1aff2177e1
common/search_charts: 51c3934f12f050fb2476d62da335a65c
common/security: 4b34923fef858a2b9a4a914c3e822889
common/segment: e8908115453de180bbda7478ba4c2d50
common/segments: 271db72d5b973fbc5fadab216177eaae
@@ -407,7 +386,6 @@ checksums:
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf
common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836
common/soon: b12e79beb0aef9414a445a1b95dd4322
common/sort_by: 8adf3dbc5668379558957662f0c43563
common/start_free_trial: e346e4ed7d138dcc873db187922369da
common/status: 4e1fcce15854d824919b4a582c697c90
@@ -415,7 +393,6 @@ checksums:
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
common/string: 4ddccc1974775ed7357f9beaf9361cec
common/styling: 240fc91eb03c52d46b137f82e7aec2a1
common/subheader: 73a37d57cb9807e574a42bd0c7e334ed
common/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e
common/summary: 13eb7b8a239fb4702dfdaee69100a220
common/survey: b659d270a53dada994d926e0cc6e9a54
@@ -424,7 +401,6 @@ checksums:
common/survey_languages: 93e4a10ab190e6b1e1f7fe5f702df249
common/survey_live: d1f370505c67509e7b2759952daba20d
common/survey_paused: c770d174d6b57e8425a54906a09c8b39
common/survey_scheduled: 704c5e76b90ea2972ad6cae50f68dcdd
common/survey_type: 417fcfecf8eaedefc4f11172426811f9
common/surveys: 33f68ad4111b32a6361beb9d5c184533
common/table_items_deleted_successfully: 46f29d20b26ecfbaf34d4e7291e88b05
@@ -487,6 +463,7 @@ checksums:
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
common/yes: ec580fd11a45779b039466f1e35eed2a
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
common/you_have_reached_your_limit_of_workspace_limit: 506a6ee315d9754da7ea26929bc40f52
@@ -502,11 +479,11 @@ checksums:
emails/email_footer_text_2: 6fd719fe916a7155e1b0b88a72420717
emails/email_template_text_1: 86b06d249ef069e0fe2457fe3889c416
emails/embed_survey_preview_email_didnt_request: 895b4463965beac0df080ccdef847824
emails/embed_survey_preview_email_environment_id: 4dc6803f9b79c8a4be35a4126a582331
emails/embed_survey_preview_email_fight_spam: 71f345df8d483d5b6c2c6dbab458d0b2
emails/embed_survey_preview_email_heading: 2a1982b3aeb91476cefb62554031394a
emails/embed_survey_preview_email_subject: 3240ad34aac02860909e10187745070e
emails/embed_survey_preview_email_text: 2eceb6fdedef104db8cfad4de7fdb9aa
emails/embed_survey_preview_email_workspace_id: bafef925e1b57b52a69844fdf47aac3c
emails/forgot_password_email_change_password: fe6d4ba303b82f4833b3293f0c4e88c0
emails/forgot_password_email_did_not_request: 79d35c3800e23e9d4c95bf33f250104f
emails/forgot_password_email_heading: fe6d4ba303b82f4833b3293f0c4e88c0
@@ -593,7 +570,6 @@ checksums:
s/question_preview: 9d8fbc0150fc10ba851beba2d4f4d9f3
s/response_already_received: 8e7b1a7d6e01a1939bca95285af77a69
s/response_submitted: d5df62e1db6012bd1283126a8dd7bad6
s/scheduled: 0ec63111fd5efe2ac240912105c5f518
s/survey_already_answered_heading: 4783c9c36ad0d4f8eec5dfeb14a04545
s/survey_already_answered_subheading: 40cfe7e8680cd4fbb7318a05d3491c78
s/survey_sent_to: 192c2b0d27e01c35953b851d3875722e
@@ -719,10 +695,6 @@ checksums:
templates/career_development_survey_question_6_choice_6: 79acaa6cd481262bea4e743a422529d2
templates/career_development_survey_question_6_headline: 88d2a87cbf2ec21882798890990c2225
templates/career_development_survey_question_6_subheader: b9b478e967930358b0c74324a7c18fc8
templates/ces: 49fc8d0ae7b82f3e7d49922ada7ab7a1
templates/ces_description: 66f4aaa7e76fd87d19c4ec3bf71481e0
templates/ces_lower_label: c2f05d3610d8879ae503a61d49e32e80
templates/ces_upper_label: b88eaddaea17a4f285209c2529a9b8f8
templates/cess_survey_name: dd706043a56d66f2895cad743935c5b4
templates/cess_survey_question_1_headline: 70115a7960746a05acef03f815652fc3
templates/cess_survey_question_1_lower_label: 586eedbc7b53319775e42c7cd4cef4de
@@ -786,9 +758,7 @@ checksums:
templates/consent_description: d76e48fb1e8c291b51e783eaf7fc910d
templates/contact_info: 73913230e8988f5f423e54e0fd43f368
templates/contact_info_description: 0e8962e628bb0a072a4217ae172db43b
templates/csat: 6864fe0caad3b052a4ec0837e7b71cee
templates/csat_description: 0e64d5594f961e5070a95f715594549e
templates/csat_lower_label: 206c68e770b90abd737c8c4cb99aa695
templates/csat_description: 4dd35d7fecfa9fdf47765c7108c3d535
templates/csat_name: f216066cef52693bbaa842a3305377c7
templates/csat_question_10_headline: b6a9ca9c6c20dced146d817c9a1e9be7
templates/csat_question_10_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
@@ -864,7 +834,6 @@ checksums:
templates/csat_survey_question_2_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0
templates/csat_survey_question_3_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/csat_upper_label: a3a49eb9cc86972bce6dc41a107f472d
templates/cta_description: bc94a2ddc965b286a8677b0642696c7e
templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa
templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e
@@ -1030,11 +999,6 @@ checksums:
templates/gauge_feature_satisfaction_question_2_headline: 0fcbefbfcf5c21e42de8a36cb2cad854
templates/identify_customer_goals_description: c30d06df9e5c76334e4c3d470ee6e4d8
templates/identify_customer_goals_name: f8123dbfa22e169517a811fae7496595
templates/identify_customer_goals_question_1_choice_1: a6803cfbdbd6208eedf5c691f9e106a5
templates/identify_customer_goals_question_1_choice_2: 7461749517d62030ec2e3915cf1d223b
templates/identify_customer_goals_question_1_choice_3: 725eb3ee0d4f2d229fcf588c21e66a86
templates/identify_customer_goals_question_1_choice_4: 3985521036afaf1cbd2bdc7a4d86d351
templates/identify_customer_goals_question_1_headline: 45a7347cf3ae2d498a30ca1266898cf8
templates/identify_sign_up_barriers_description: 5b2fbee8c425d7a4d0706ec3628cea11
templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd
templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84
@@ -1109,8 +1073,6 @@ checksums:
templates/improve_trial_conversion_question_1_subheader: 67c7047ba2365d461df14dbed3f9506d
templates/improve_trial_conversion_question_2_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_2_headline: 54324cd652667183dd3cf647ba72dd07
templates/improve_trial_conversion_question_3_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_3_headline: 8dfe1f843c8de64de7e3fa619b961152
templates/improve_trial_conversion_question_4_button_label: d94a6a11cfdf4ebde4c5332e585e2e96
templates/improve_trial_conversion_question_4_headline: 9b07341f65574c4165086ec107cebb45
templates/improve_trial_conversion_question_4_html: 8ce95691eeeae7ad61c4d2f867b918ca
@@ -1578,6 +1540,7 @@ checksums:
workspace/actions/this_action_will_be_triggered_when_the_page_is_loaded: 8d28f30cf56f50ea79aef8dd2b02185d
workspace/actions/this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page: 60230ad129853377af1066e10f3b3c22
workspace/actions/this_action_will_be_triggered_when_the_user_tries_to_leave_the_page: 504fa734659524933e3489823d820265
workspace/actions/this_is_a_code_action_please_make_changes_in_your_code_base: 27e64863fdebca791ca129ac0c3da3d5
workspace/actions/time_in_seconds: 822be76950e5f614ed23f52ba1d4825f
workspace/actions/time_in_seconds_placeholder: e54a4c40e0c6b43fb2e97bd32cab8da8
workspace/actions/time_in_seconds_with_unit: a743b7844c71ddad364a93872682ae9e
@@ -1592,182 +1555,6 @@ checksums:
workspace/actions/you_can_track_code_action_anywhere_in_your_app_using: 3c0bbf160b8ddbeef142403103b70554
workspace/actions/your_survey_would_be_shown_on_this_url: 766fdeeb52d170c156af5d035a1f8c37
workspace/actions/your_survey_would_not_be_shown: af44fe160f449ff9557ebe5d3686832d
workspace/analysis/charts/OR: 0208d355f231c386b19390f0bea41b95
workspace/analysis/charts/add_chart_to_dashboard: c2a517ada86cdda60e49bec655ca9a6d
workspace/analysis/charts/add_chart_to_dashboard_description: 08980a1849757e9aec21fca5881c6be4
workspace/analysis/charts/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
workspace/analysis/charts/add_to_dashboard: 9941c3d30895bb8e25ce8d4e03d33a08
workspace/analysis/charts/advanced_chart_builder_config_prompt: c2fe2c1a076f27d3ae62a4db75474b0a
workspace/analysis/charts/ai_query_placeholder: 24c3d18f514cb3a9953f04c3b04503a2
workspace/analysis/charts/ai_query_section_description: 66d06342f29bf6658793403856521fd7
workspace/analysis/charts/ai_query_section_title: c0e450a47af7c2a516b77f73cf54db1b
workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df
workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
workspace/analysis/charts/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
workspace/analysis/charts/chart_added_to_dashboard: 7bc429ab605cb89a9232c26be008cc00
workspace/analysis/charts/chart_builder_choose_chart_type: 1376de2dcafac573a2df9e4c007b0ec8
workspace/analysis/charts/chart_data: 6739a9576b357a58d73ff0c9bf8db0e4
workspace/analysis/charts/chart_data_tab: b7b46ab6ce9606032c8f81f6f6afbb9b
workspace/analysis/charts/chart_deleted_successfully: 79148f471cd9acc2c8d0d033fb85437e
workspace/analysis/charts/chart_deletion_error: 267eb65c168e726075d7cea678dd32e0
workspace/analysis/charts/chart_duplicated_successfully: 755c4ce5bf533764d549a53c33e32165
workspace/analysis/charts/chart_duplication_error: 90d7166c85188b52f821c9d9f53ff8c4
workspace/analysis/charts/chart_name: cdb36e2f121a7b9c28298e15ab8218dc
workspace/analysis/charts/chart_name_placeholder: 7370d4f88f27aea337ba1c36465c3f8b
workspace/analysis/charts/chart_preview: 1b7faae244d31e43f758f50b94132413
workspace/analysis/charts/chart_render_error: 01e9ece0c86a1fedf301afa0dbbf6aeb
workspace/analysis/charts/chart_saved_successfully: 2489c853c0b36790e3592ac6ea31cc61
workspace/analysis/charts/chart_type_area: 535754c6425f045f17e1dcb551840c93
workspace/analysis/charts/chart_type_bar: c11d460595d3ddfe8efd67ac068574c5
workspace/analysis/charts/chart_type_big_number: 9d17fb96241507c955dca25e143ae67a
workspace/analysis/charts/chart_type_line: f42dd53238ed4d44def306a61d47d5c4
workspace/analysis/charts/chart_type_not_supported: c25334de42fd6192ff8355158865a3e8
workspace/analysis/charts/chart_type_pie: 068a797404233ccf68d07ad63af7b50c
workspace/analysis/charts/chart_updated_successfully: a2c210523902c726aa1328bbeda0b357
workspace/analysis/charts/configure_description: 2939321f78e4ffbc57b4259ddaddb09d
workspace/analysis/charts/configure_title: ab767b11da1d386b98b3f634f79d3abe
workspace/analysis/charts/configure_type_label: cd13e4b37fb2021af55903e7690a9856
workspace/analysis/charts/contains: 06dd606c0a8f81f9a03b414e9ae89440
workspace/analysis/charts/create_chart: 636969b904e88bef5c93e859dd8a1656
workspace/analysis/charts/create_chart_description: b9680bd8905dea180fa59a86f61de34e
workspace/analysis/charts/create_chart_with_ai: b0092b5616015a29dd51fbab49bcd4c4
workspace/analysis/charts/custom_range: 99f4d72b64621406acc162cceeb1fed7
workspace/analysis/charts/dashboard: c9380ea68c8c76ea451bd9613329a07c
workspace/analysis/charts/dashboard_select_placeholder: 9b875f2f10050d650ae63be53fe0d4e8
workspace/analysis/charts/data_label: b7b46ab6ce9606032c8f81f6f6afbb9b
workspace/analysis/charts/data_source: c29cdd1967a3d1b1a39e91e14469b047
workspace/analysis/charts/date_preset_last_30_days: a738894cfc5e592052f1e16787744568
workspace/analysis/charts/date_preset_last_7_days: 3631df3109bfecfe358ba15dcf8bd6f5
workspace/analysis/charts/date_preset_last_month: 848086395b28875c050d56e3933dae61
workspace/analysis/charts/date_preset_this_month: 50845a38865204a97773c44dcd2ebb90
workspace/analysis/charts/date_preset_this_quarter: 9c77d94783dff2269c069389122cd7bd
workspace/analysis/charts/date_preset_this_year: 1e69651c2ac722f8ce138f43cf2e02f9
workspace/analysis/charts/date_preset_today: 142173f9752e18e92109623a3ee68cad
workspace/analysis/charts/date_preset_yesterday: eeb58908e68ff96c1b7e8f90e389afb7
workspace/analysis/charts/date_range: 9b3aa5954144de586931f60ef9594e99
workspace/analysis/charts/delete_chart_confirmation: f7fd7b0a08e81c9b392b08c9c1ad2147
workspace/analysis/charts/dimensions: f09d837ac25f58986a769bd48ea15022
workspace/analysis/charts/dimensions_toggle_description: 31eb28f3c83c04bbe37799758ca9f595
workspace/analysis/charts/edit_chart_description: 822890e4b6068096e2fe8b7b78b4474f
workspace/analysis/charts/edit_chart_title: fd3e7f8c53280bfad8f4034c055f4c71
workspace/analysis/charts/enable_time_dimension: cfcf0af2d22bccd197319c07680c2cb8
workspace/analysis/charts/end_date: acbea5a9fd7a6fadf5aa1b4f47188203
workspace/analysis/charts/enter_a_name_for_your_chart: b6e992a23d0628136121ebf26eec4a50
workspace/analysis/charts/enter_value: a4554ed67c02872e302b0042724f859d
workspace/analysis/charts/equals: 264ec282f7f5b67da622cc37f2b57b8a
workspace/analysis/charts/failed_to_add_chart_to_dashboard: 355a5606399edcbb3e6d0ba0b66f12a6
workspace/analysis/charts/failed_to_execute_query: d1153133aa4cd3d1cd02e39942413168
workspace/analysis/charts/failed_to_load_chart: abea098fbf8e728f95414d3ae8bb63a4
workspace/analysis/charts/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
workspace/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5
workspace/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9
workspace/analysis/charts/field_label_average_score: 5b5aa7322549521d1e813b1c8312d443
workspace/analysis/charts/field_label_collected_at: b41902ddb4586ba4a4611d726b5014aa
workspace/analysis/charts/field_label_count: 9c5848662eb8024ddf360f7e4001a968
workspace/analysis/charts/field_label_detractor_count: eedb15bc383eb0f14d43043e6666c62a
workspace/analysis/charts/field_label_emotion: eb3a31ead51b5c8a8d365d5f904e9206
workspace/analysis/charts/field_label_field_type: 2581066dc304c853a4a817c20996fa08
workspace/analysis/charts/field_label_nps_score: 9c8d0b0b460f9689bd66e81d45e0a2df
workspace/analysis/charts/field_label_nps_value: cb7404025044400e3d7d5600f3133e4f
workspace/analysis/charts/field_label_passive_count: ceb71da8d1382eb2097089dc3ecf76da
workspace/analysis/charts/field_label_promoter_count: c393131a4bd3a25bf6b297beed20e34f
workspace/analysis/charts/field_label_response_id: 73375099cc976dc7203b8e27f5f709e0
workspace/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6
workspace/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61
workspace/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570
workspace/analysis/charts/field_label_topic: 7f542b783cd528f00f4f485e35b48dc1
workspace/analysis/charts/field_label_user_identifier: b0174469c95038766744fb7e64005aec
workspace/analysis/charts/filter_data: 05cc68ed2896feef60bbe3829cd9063d
workspace/analysis/charts/filters: acf5accc113ff3c1992688058576732c
workspace/analysis/charts/filters_toggle_description: ea18bdb212a6a85620125cab89a4b1c1
workspace/analysis/charts/go_to_feedback_record_directories: 1aa0516beef8adbd330cffdcab8b521f
workspace/analysis/charts/granularity: 9eb09aef092e7803ce4acb7965cbbaa9
workspace/analysis/charts/granularity_day: 47648cd60fc313bc3f05b70357a1d675
workspace/analysis/charts/granularity_hour: ec3113f22fc51d01f0c615c5496f8f87
workspace/analysis/charts/granularity_month: ae7bef950efc406ff0980affabc1a64c
workspace/analysis/charts/granularity_quarter: 7a68ec90d7c90b92b7bb873834a00381
workspace/analysis/charts/granularity_week: 436fdd694160827dd6ea4644cdd0a8f8
workspace/analysis/charts/granularity_year: ed86f5f60583f9d8ffdbeed306aa0ec7
workspace/analysis/charts/greater_than: a4c18b3b45fcaf7c83bf489cf2b506d4
workspace/analysis/charts/greater_than_or_equal: d453e26d136847560148168797fece51
workspace/analysis/charts/group_by: 3f1cedea7783018ce83f2fab0051a738
workspace/analysis/charts/group_by_description: a4a85baaca87c172023cbe87e620118b
workspace/analysis/charts/group_data: 55c0035773d8c6b7f4d96363a61cda82
workspace/analysis/charts/is_not_set: 906801489132487ef457652af4835142
workspace/analysis/charts/is_set: 9850468156356f95884bbaf56b6687aa
workspace/analysis/charts/less_than: fb41255dd44bb6de78617b078610c91b
workspace/analysis/charts/less_than_or_equal: da4a2816aadf788d33efcdcc3c61802e
workspace/analysis/charts/measures: b1e6cf0f356dda0052c4fef4ad4957a2
workspace/analysis/charts/no_charts_found: d4a27d5b56e49ebdd38bf28791dbcc42
workspace/analysis/charts/no_dashboards_available: f88389b6c5278cfc4d5b360031205dfe
workspace/analysis/charts/no_dashboards_create_first: 28ded0d72247191eb23f6f77925df539
workspace/analysis/charts/no_data_available: fe1d34a45e22b5611d255b84b2d67232
workspace/analysis/charts/no_data_returned: 683acf7b4f3b32aa85fa26f1bb948d4f
workspace/analysis/charts/no_data_returned_for_chart: b9ff6c85697c683f40b3d0c05eeb2046
workspace/analysis/charts/no_data_source_available: 48179160e288de4a9e00f0bf110a5ced
workspace/analysis/charts/no_grouping: e3a6943e61407600cae057e0833a482d
workspace/analysis/charts/no_valid_data_to_display: d1ba2b0686520c0a2c62ee73daa1c9c9
workspace/analysis/charts/not_contains: 5894f5474271b8902d7892e43500d227
workspace/analysis/charts/not_equals: 427715f1ea349965c36f5c628784eb08
workspace/analysis/charts/open_chart: 729a54bbc4bcb3f431865af5e5a50dd4
workspace/analysis/charts/open_options: 2c6a35fec9b9d008e41728594bcd07d7
workspace/analysis/charts/or_filter_logic: 0208d355f231c386b19390f0bea41b95
workspace/analysis/charts/original: 7e55782bdf7cb49f5616b326c003c278
workspace/analysis/charts/please_enter_chart_name: 9258b71b2cb09d22ffe33de1755e7309
workspace/analysis/charts/please_enter_filter_values: ca79dfab463a3836863618fd92f82b3e
workspace/analysis/charts/please_select_at_least_one_dimension: 32ea97a02bb6826947bb70389d1a6231
workspace/analysis/charts/please_select_at_least_one_measure: d4163ede267f71ee65945f453e14ff7b
workspace/analysis/charts/please_select_dashboard: 8f062db96f815ed8268584dd8d292fa6
workspace/analysis/charts/predefined_measures: 7651141f62c991954edcff70899b2a8b
workspace/analysis/charts/preset: a17bb0bf56f3326c9567be3ea896ee19
workspace/analysis/charts/preview_chart: 8db30f87ba44165401f340a1ee7f549b
workspace/analysis/charts/query_executed_successfully: 9d6f9dad526fcfe0161757c2d2fe2c69
workspace/analysis/charts/reset_to_ai_suggestion: 51ced8dd7c0eea8b7fc4e08b35cfbf30
workspace/analysis/charts/save_and_add_to_dashboard: a76ed91c62dae10c5f8a9d48cbacd566
workspace/analysis/charts/save_chart: 2e4505f7bf3d1c35b0b37b1e9d3dc566
workspace/analysis/charts/save_chart_dialog_title: 2e4505f7bf3d1c35b0b37b1e9d3dc566
workspace/analysis/charts/select_data_source: 983394bc0182b65ec68f713a46b97302
workspace/analysis/charts/select_data_source_first: 82a02846de9d6351595c97a0929f3b9a
workspace/analysis/charts/select_dimensions: 6d0d038d027ef9e641bf9b7700edac9f
workspace/analysis/charts/select_field: 45665a44f7d5707506364f17f28db3bf
workspace/analysis/charts/select_measures: c9f101aeb53bf0d4abdd652aaf60a1bf
workspace/analysis/charts/select_preset: e68bad9a209a6ca35c62184f1f1d829c
workspace/analysis/charts/showing_first_n_of: e9c1e76a46d0635f775a5b86bddbe1c3
workspace/analysis/charts/start_date: 881de78c79b56f5ceb9b7103bf23cb2c
workspace/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84
workspace/analysis/charts/time_dimension_title: 9353ce9a075a0cc8c3ba7dfa9ef19a8d
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
workspace/analysis/charts/update_chart: 7d9223335d9f0c5938ec30356d7034a9
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
workspace/analysis/dashboards/charts_add_failed: c4fda79ede798ab6747a989f083a0947
workspace/analysis/dashboards/charts_add_partial_failure: b1a9fc6fe18ab20fe16c16e91a05c195
workspace/analysis/dashboards/charts_added_to_dashboard: 917abab80231adf5af51e812352fc77b
workspace/analysis/dashboards/charts_load_failed: 190bf9c13d3c3cf18126a263591d6757
workspace/analysis/dashboards/create_dashboard: bedb308708fe9c576e161a2fa16d3439
workspace/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
workspace/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
workspace/analysis/dashboards/create_new_chart: e03c0fdf4b861454c09707d66fb9bf4c
workspace/analysis/dashboards/create_success: 1fa4dea7702ba03a8a3533295276ff1b
workspace/analysis/dashboards/dashboard: c9380ea68c8c76ea451bd9613329a07c
workspace/analysis/dashboards/dashboard_delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
workspace/analysis/dashboards/dashboard_name: a2d344bc03f27706b42d7d6a8d0fc752
workspace/analysis/dashboards/dashboard_name_placeholder: 02954eeb5671f1c00e3f69b47319916e
workspace/analysis/dashboards/dashboard_name_required: 4a56c3ce1d73ad915815f5de4bcff566
workspace/analysis/dashboards/dashboard_save_failed: 2b6c7be7947bc7ebb0389b71b5922ba6
workspace/analysis/dashboards/dashboard_saved: 6eb27743b6b12d3d0a20b430319890b8
workspace/analysis/dashboards/delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
workspace/analysis/dashboards/delete_failed: b108acc28b1f9abcb544a358a958b54b
workspace/analysis/dashboards/delete_success: 9d161634daab9ea9d17fbfb413eeeffa
workspace/analysis/dashboards/duplicate_failed: 6ebaf8ad373b156f88f1ed79a5efd441
workspace/analysis/dashboards/duplicate_success: 37cbb14143776d4c215432673e32ebd9
workspace/analysis/dashboards/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
workspace/analysis/dashboards/no_dashboards_found: e049ec0356009c3a0aa2c729d916efc6
workspace/analysis/dashboards/no_data_message: 464d50cf30281a5b6af2726846eb14b4
workspace/analysis/dashboards/please_enter_name: b9211ed8a0882c0e0109beba48685d68
workspace/analysis/manage_feedback_sources: 6aa6a82334ab680b5aa187b7245e8ec8
workspace/analysis/no_feedback_records_message: 67d6ebb9c040304789017d795ca474fc
workspace/analysis/no_feedback_records_with_sources_message: 4b72636a55afb4dcf977161ad5a15467
workspace/analysis/setup_feedback_source: 7cc5855a2b0c762fe2ae13b4921f3e92
workspace/api_keys/add_api_key: 3c7633bae18a6e19af7a5af12f9bc3da
workspace/api_keys/api_key: ce825fec5b3e1f8e27c45b1a63619985
workspace/api_keys/api_key_copied_to_clipboard: daeeac786ba09ffa650e206609b88f9c
@@ -1778,24 +1565,20 @@ checksums:
workspace/api_keys/api_key_updated: 0e03754eb33742b4ee8d5fdad64c9b3f
workspace/api_keys/delete_api_key_confirmation: b2f0342d4e55f0cb244fe121eeeb10a3
workspace/api_keys/duplicate_access: 7ac7ac5ba755ce94e6fc81afa5a21997
workspace/api_keys/duplicate_directory_access: e112b756627f0b5e2551451274c3781f
workspace/api_keys/feedback_record_directory_access: 51babe735cb94388f68108555814b4f6
workspace/api_keys/no_api_keys_yet: 58593ed9f7e507dcd7ca7fe069add599
workspace/api_keys/no_directory_permissions_found: ae55b273dd4c71d8431d64d99b39c59f
workspace/api_keys/no_workspace_permissions_found: 1d719624828a9d3e433cdf6b387549f3
workspace/api_keys/no_env_permissions_found: 97ef49946f3ce15c2ad44dcfd2bce507
workspace/api_keys/organization_access: 96a92fa907b15e0c0e47e33cac15be88
workspace/api_keys/organization_access_description: 773dfeaf6ffbf34dd9a0a3d656a6d83c
workspace/api_keys/permissions: 2160be68b1d6b6577e64634e9feba2ed
workspace/api_keys/secret: f041e5eb96121c8b4f2b8af7e0f83a9b
workspace/api_keys/unable_to_copy_api_key: 148506832e31d033fa3569ce292d2120
workspace/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
workspace/api_keys/unknown_directory: ed07f55f5dba1f451a45f2cf6e01c9a9
workspace/api_keys/unknown_workspace: 4b0df2d07ebc9ab084158b1b9525ae5e
workspace/api_keys/workspace_access: b38cb73197ef5f5fa6653b88c68aa0bd
workspace/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
workspace/app-connection/app_connection_description: dde226414bd2265cbd0daf6635efcfdd
workspace/app-connection/cache_update_delay_description: 3368e4a8090b7684117a16c94f0c409c
workspace/app-connection/cache_update_delay_title: 60e4a0fcfbd8850bddf29b5c3f59550c
workspace/app-connection/environment_id: 49141af65970ea79e22ecedb97ceb2e4
workspace/app-connection/environment_id_description: d611755139dbe9865f1436acf6f679be
workspace/app-connection/formbricks_sdk_connected: 29e8a40ad6a7fdb5af5ee9451a70a9aa
workspace/app-connection/formbricks_sdk_not_connected: 557c534e665750978ba6edb0eacb428e
workspace/app-connection/formbricks_sdk_not_connected_description: 4ddbacae084238bd0cefeded0fe9dbb9
@@ -1804,12 +1587,10 @@ checksums:
workspace/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c
workspace/app-connection/recheck: f95f2bbe6990a123d60255c87bdd59f7
workspace/app-connection/sdk_connection_details: 89f2c169fd1604c1df5a834517f1eae1
workspace/app-connection/sdk_connection_details_description: 2d6824466039672fa002d72da95b4637
workspace/app-connection/sdk_connection_details_description: 8e6d79678736819bf2f2940404ba5c3e
workspace/app-connection/setup_alert_description: 6d676044d01dc2147731ffab7df6c259
workspace/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
workspace/app-connection/webapp_url: d64d8cc3c4c4ecce780d94755f7e4de9
workspace/app-connection/workspace_id: 49141af65970ea79e22ecedb97ceb2e4
workspace/app-connection/workspace_id_description: 77e5219be241e9973741f138787ccbb8
workspace/connect/congrats: c2f5b597aabdf298cf9f0452863e2dc6
workspace/connect/connection_successful_message: fa1f29883e15e8697c6c477bdf5cb645
workspace/connect/do_it_later: ab4accfbe53d924ab3ffaf9ea78a75f3
@@ -1836,10 +1617,10 @@ checksums:
workspace/contacts/attribute_value_placeholder: 90fb17015de807031304d7a650a6cb8c
workspace/contacts/attributes_msg_attribute_limit_exceeded: a6c430860f307f9cc90c449f96a1284f
workspace/contacts/attributes_msg_attribute_type_validation_error: bd70f9773ae873240d4cdb26a662334c
workspace/contacts/attributes_msg_email_already_exists: 308cf739f7b98a6b2707acf3b658f220
workspace/contacts/attributes_msg_email_already_exists: a3ea1265e3db885f53d0e589aecf6260
workspace/contacts/attributes_msg_email_or_userid_required: febc8b0cda4dd45d2c3cdb1ac2d45dcb
workspace/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
workspace/contacts/attributes_msg_userid_already_exists: 94851fa7f17ffd0da323658dbf6bdd31
workspace/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
workspace/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
workspace/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
workspace/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
@@ -2084,6 +1865,7 @@ checksums:
workspace/languages/duplicate_language_or_language_id: 0e17e3794b24e2428ca6ffadae0d08f3
workspace/languages/edit_languages: c9d36f6b28557cc7d54e87c37dc18fdd
workspace/languages/identifier: 7d8ade6b85e96216bcd73adeeeeecd8c
workspace/languages/incomplete_translations: d82908b5725f18f5849c7876ad497ebc
workspace/languages/language: 277fd1a41cc237a437cd1d5e4a80463b
workspace/languages/language_deleted_successfully: 4a805d030491f3fe608d2371b0cfcd83
workspace/languages/languages_updated_successfully: 60de474c99c5059c0458cddd0b016c15
@@ -2094,6 +1876,7 @@ checksums:
workspace/languages/remove_language: 1a64563b0f37109f97b78eddd493e381
workspace/languages/remove_language_from_surveys_to_remove_it_from_workspace: 61bc96f9db31a29a649cc9ecd684bc39
workspace/languages/search_items: b54b751c8b075200be579d6c8e58096b
workspace/languages/translate: 59f9803b27e2030ba7323ed239116cf7
workspace/look/add_background_color: 9be512ee1246e32d3958c56097d202d9
workspace/look/add_background_color_description: adb6fcb392862b3d0e9420d9b5405ddb
workspace/look/advanced_styling_field_border_radius: 63b8f3541a9792d705e67d5aca7b6451
@@ -2298,7 +2081,7 @@ checksums:
workspace/settings/billing/most_popular: 03051978338d93d9abdd999bc06284f9
workspace/settings/billing/pending_change_removed: c80cc7f1f83f28db186e897fb18282a3
workspace/settings/billing/pending_plan_badge: 1283929a2810dcf6110765f387dc118e
workspace/settings/billing/pending_plan_change_description: 6923bada769d33cadcad557521362c1f
workspace/settings/billing/pending_plan_change_description: a50400c802ab04c23019d8219c5e7e1c
workspace/settings/billing/pending_plan_change_title: 730a8df084494ccf06c0a2f44c28f9fc
workspace/settings/billing/pending_plan_cta: 1283929a2810dcf6110765f387dc118e
workspace/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4
@@ -2451,20 +2234,19 @@ checksums:
workspace/settings/feedback_record_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
workspace/settings/feedback_record_directories/no_access: cc3385cd01a11e3949003a2cc6fb5b31
workspace/settings/feedback_record_directories/no_connectors: b1becb4fe4e2ba7c5d277db149f092ff
workspace/settings/feedback_record_directories/pause_connectors_confirmation_description: a3c2c56daed9f2a9e6a853cb8b924bad
workspace/settings/feedback_record_directories/pause_connectors_confirmation_title: 09041363c55fb2686f8115df6fa2afc1
workspace/settings/feedback_record_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
workspace/settings/feedback_record_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
workspace/settings/feedback_record_directories/title: e3d425c27f80162f29ce094e31a3fd8f
workspace/settings/feedback_record_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
workspace/settings/feedback_record_directories/unarchive_workspace_conflict: 82f4b8ebaf41589cfb96e6398dafcc76
workspace/settings/feedback_record_directories/workspace_access: 32407b39cf878fb579559c1ed3660892
workspace/settings/general/ai_data_analysis_disabled_for_organization: 2066fe71ecf8994ba738c79b63a1934b
workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
workspace/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
workspace/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
workspace/settings/general/ai_enabled_description: e8c3e9f362588898a6cea85e18c013a1
workspace/settings/general/ai_features_not_enabled_for_organization: e344473bd813fc43f69c51138f74bc8e
workspace/settings/general/ai_instance_not_configured: 37a80753eb22b5bfc985d0e1f2145e3f
workspace/settings/general/ai_settings_updated_successfully: 2a6f534dc3a246ced46becd8a4a9543d
workspace/settings/general/ai_smart_tools_disabled_for_organization: 13df84ae47d35dfa6e86ffa62f29c75d
workspace/settings/general/ai_smart_tools_enabled: 1dda984f5262c5f9120ee9a409236758
workspace/settings/general/ai_smart_tools_enabled_description: 1ceca6707746d3ab4a530712a06d91da
workspace/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37
@@ -2523,8 +2305,6 @@ checksums:
workspace/settings/general/share_invite_link: b40b7ffbcf02d7464be52fb562df5e3a
workspace/settings/general/share_this_link_to_let_your_organization_member_join_your_organization: 6eb43d5b1c855572b7ab35f527ba953c
workspace/settings/general/test_email_sent_successfully: aa68214f5e0707c9615e01343640ab32
workspace/settings/general/unlock_ai_features_description: c15c8c050a4a16d99dc595d9c6419bc4
workspace/settings/general/unlock_ai_features_with_a_higher_plan: e0140d3ffd07524fb8f1fec637c4149a
workspace/settings/notifications/auto_subscribe_to_new_surveys: 8102c9ce2fbcae53bd8d979c42932fa9
workspace/settings/notifications/email_alerts_surveys: 12be5a073d74453a531167debd947bd6
workspace/settings/notifications/every_response: 526988e9015f37bc2d32414d7dc05c7c
@@ -2613,9 +2393,16 @@ checksums:
workspace/settings/teams/you_are_a_member: cf5af638d5371c8fbc337e92519e5150
workspace/surveys/all_set_time_to_create_first_survey: 21d3bb74c3b9642b3195d17c17346399
workspace/surveys/alphabetical: 5fcfeff9c5fd28714f0a390e0ddaaaee
workspace/surveys/copy_survey: de8142b45e7bca61f2dca0069a62b417
workspace/surveys/copy_survey_description: b78f714a4a4baae883210b13fb196bd5
workspace/surveys/copy_survey_error: 74cab7d84ea8b669e106d4c326cac005
workspace/surveys/copy_survey_link_to_clipboard: 77387e3d3de4be07a2a34963f73cd7e8
workspace/surveys/copy_survey_no_workspaces: 6f4547d91b2c14dad83c44b01df365eb
workspace/surveys/copy_survey_partially_success: a436a5fb7167b95c2308794d35aab070
workspace/surveys/copy_survey_success: a829e645fe034b3e712d0b8572a5edc4
workspace/surveys/delete_survey_and_responses_warning: 3320c91c1fd27378b7f3d6abc003f2ae
workspace/surveys/edit/activate_translations: af127c1bed2b47e2012e3a23e489ecb8
workspace/surveys/edit/1_choose_the_default_language_for_this_survey: d22759857c1bb3d6b337e8e9d501dad7
workspace/surveys/edit/2_activate_translation_for_specific_languages: 9f23cb81ad301073df45ae36f0d94f9e
workspace/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66
workspace/surveys/edit/add_a_delay_or_auto_close_the_survey: b5fa358bf3ff324014060eb0baf6dd2f
workspace/surveys/edit/add_a_four_digit_pin: 953cb3673d2135923e3b4474d33ffb2c
@@ -2652,18 +2439,6 @@ checksums:
workspace/surveys/edit/adjust_survey_closed_message: ae6f38c9daf08656362bd84459a312fa
workspace/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
workspace/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
workspace/surveys/edit/ai_data_analysis_disabled: 2066fe71ecf8994ba738c79b63a1934b
workspace/surveys/edit/ai_features_not_enabled: e344473bd813fc43f69c51138f74bc8e
workspace/surveys/edit/ai_instance_not_configured: 939ad7c3240fa8de98a325239f1b36bc
workspace/surveys/edit/ai_smart_tools_disabled: 13df84ae47d35dfa6e86ffa62f29c75d
workspace/surveys/edit/ai_translate: f25943cdeffe155ee524428f4daa5da2
workspace/surveys/edit/ai_translating: 098a2293b39f9f258d67f926cf03df37
workspace/surveys/edit/ai_translation_all_fields_populated: d78f6a663ea19ce77045970179bd200f
workspace/surveys/edit/ai_translation_complete: f443d0801404f728e68000b46ca67598
workspace/surveys/edit/ai_translation_failed: fd356a173d0abde7a0fc660394954cc7
workspace/surveys/edit/ai_translation_instance_not_configured: 6deeb8aeaff3982d07e1d5a045e06d2d
workspace/surveys/edit/ai_translation_not_available: 2f060bf93a558e6d12ec90988fdd162e
workspace/surveys/edit/ai_translation_not_enabled: 9066bc85f62ea0e96620c058a4004388
workspace/surveys/edit/all_are_true: 05d02c5afac857da530b73dcf18dd8e4
workspace/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
workspace/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
@@ -2677,7 +2452,7 @@ checksums:
workspace/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
workspace/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
workspace/surveys/edit/auto_progress_rating_and_nps: 76b98e95a5b850850baa0ccc3c7fbf7c
workspace/surveys/edit/auto_progress_rating_and_nps_description: 2a992dd8a5b9532f178f9a21881feb9a
workspace/surveys/edit/auto_progress_rating_and_nps_description: cbf676789b9f3f47e36bdf35fa58282b
workspace/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
workspace/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
workspace/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
@@ -2723,7 +2498,6 @@ checksums:
workspace/surveys/edit/caution_text: 3291e962c0e4c4656832837ddc512918
workspace/surveys/edit/change_anyway: 6377497d40373f6d0f082670194981ab
workspace/surveys/edit/change_background: fa71a993869f7d3ac553c547c12c3e9b
workspace/surveys/edit/change_default: 6236a6c8a28489ba7c4cad7426806859
workspace/surveys/edit/change_question_type: 2d555ae48df8dbedfc6a4e1ad492f4aa
workspace/surveys/edit/change_survey_type: c26322043a476da6d94adb8b4efe1e93
workspace/surveys/edit/change_the_background_to_a_color_image_or_animation: f1b9c9eb61497dd91b2550dd50c77836
@@ -2735,11 +2509,7 @@ checksums:
workspace/surveys/edit/choose_the_first_question_on_your_block: bdece06ca04f89d0c445ba1554dd5b80
workspace/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117
workspace/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2
workspace/surveys/edit/clear_close_on_date: 673ed6940f36b02cf871ffacf034e114
workspace/surveys/edit/clear_publish_on_date: fe1ffa08c7b95d1dbd6bb8f89d22760f
workspace/surveys/edit/close_survey_on_date: 5588ecb41d245dacfb5f7e1b10a97a3e
workspace/surveys/edit/close_survey_on_response_limit: 256d0bccdbcbb3d20e39aabc5b376e5e
workspace/surveys/edit/code: 343bc5386149b97cece2b093c39034b2
workspace/surveys/edit/color: 9d53d1d120e8b8954bcae9a322573748
workspace/surveys/edit/column_used_in_logic_error: deffbd3e8f4bd71a5e522682e8ee60dd
workspace/surveys/edit/columns: 14896556dc1535d70198854757f704ec
@@ -2764,7 +2534,6 @@ checksums:
workspace/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
workspace/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
workspace/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
workspace/surveys/edit/default_language: 06d01d2598419e36ba97d2d8719f849b
workspace/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
workspace/surveys/edit/delete_block: c00617cb0724557e486304276063807a
workspace/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
@@ -2784,6 +2553,7 @@ checksums:
workspace/surveys/edit/duplicate_question: 910751de01fdd327165968214717711b
workspace/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
workspace/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
workspace/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
workspace/surveys/edit/element_not_found: 196777ff6811dd177971ffc8e27a72c1
workspace/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
workspace/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
@@ -2919,13 +2689,11 @@ checksums:
workspace/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53
workspace/surveys/edit/long_answer_toggle_description: 86bcdfeb74d9825c2f2d5a215e92d111
workspace/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160
workspace/surveys/edit/manage_languages: fe82303bc27b55ccfc076b527b185e39
workspace/surveys/edit/manage_translations: 09b01c5c251e6dbc3dc6cd8b33fb6301
workspace/surveys/edit/manage_languages: 9c56d5afee8a73dfc283a452470f3a10
workspace/surveys/edit/matrix_all_fields: 187240509163b2f52a400a565e57c67f
workspace/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151
workspace/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7
workspace/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e
workspace/surveys/edit/missing_first: a0c8802636ade7bac86a0dacba00b8d4
workspace/surveys/edit/move_question_to_block: e8d7ef1e2f727921cb7f5788849492ad
workspace/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
workspace/surveys/edit/needed_for_self_hosted_cal_com_instance: d241e72f0332177d32ce6c35070757dc
@@ -2933,7 +2701,7 @@ checksums:
workspace/surveys/edit/next_button_label: 39f1e82ae1dea5e400e8ed7c98c6ad9c
workspace/surveys/edit/no_hidden_fields_yet_add_first_one_below: 9cc6cab3a6a42dbf835215897b5b8516
workspace/surveys/edit/no_images_found_for: 7dabcbcc7084f59c6ec0971895dfcd29
workspace/surveys/edit/no_languages_found_add_first_one_to_get_started: 4e66397232da6a463708220dc020bf42
workspace/surveys/edit/no_languages_found_add_first_one_to_get_started: 22d7782c8504daf693cab3cf7135d6e3
workspace/surveys/edit/no_option_found: a1a3aa7e6c13b6bb8df20a1a104c7c04
workspace/surveys/edit/no_recall_items_found: 729e2b02e412cdc79f5ad94b1918620c
workspace/surveys/edit/no_variables_yet_add_first_one_below: c8704b9ebc9c26c0e9dd50c099ba88cd
@@ -2960,14 +2728,12 @@ checksums:
workspace/surveys/edit/please_enter_a_valid_url: 25d43dfb802c31cb59dc88453ea72fc4
workspace/surveys/edit/please_set_a_survey_trigger: 0358142df37dd1724f629008a1db453a
workspace/surveys/edit/please_specify: e1faa6cd085144f7339c7e74dc6fb366
workspace/surveys/edit/present_your_survey_in_multiple_languages: 37f28b0a092d68322fedbc2e0c221ef3
workspace/surveys/edit/prevent_double_submission: afc502baa2da81d9c9618da1c3b5a57a
workspace/surveys/edit/prevent_double_submission_description: ef7d2aa22d43bdc6ccebb076c6aa9ce5
workspace/surveys/edit/progress_saved: d7bfc189571f08bbb4d0240cb9363ffa
workspace/surveys/edit/protect_survey_with_pin: 16d1925b6a5770f7423772d6d9a8291a
workspace/surveys/edit/protect_survey_with_pin_description: 0e55d19b6f3578b1024e03606172a5d2
workspace/surveys/edit/publish: 4aa95ba4793bb293e771bd73b4f87c0f
workspace/surveys/edit/publish_survey_on_date: 3167951ef991b911d2e2abb102842452
workspace/surveys/edit/question: 0576462ce60d4263d7c482463fcc9547
workspace/surveys/edit/question_deleted: ecdeb22b81ae2d732656a7742c1eec7b
workspace/surveys/edit/question_duplicated: 3f02439fd0a8b818bc84c1b1b473898c
@@ -3036,7 +2802,6 @@ checksums:
workspace/surveys/edit/rows: 8f41f34e6ca28221cf1ebd948af4c151
workspace/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
workspace/surveys/edit/scale: 5f55a30a5bdf8f331b56bad9c073473c
workspace/surveys/edit/schedule_survey: aa9d0a74a96c325e3202bf1efbc4611a
workspace/surveys/edit/search_for_images: 8b1bc3561d126cc49a1ee185c07e7aaf
workspace/surveys/edit/seconds_after_trigger_the_survey_will_be_closed_if_no_response: 3584be059fe152e93895ef9885f8e8a7
workspace/surveys/edit/seconds_before_showing_the_survey: 4b03756dd5f06df732bf62b2c7968b82
@@ -3052,7 +2817,6 @@ checksums:
workspace/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42
workspace/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79
workspace/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
workspace/surveys/edit/show_in_order: 15784a59572eb8a6dba6b918c31a9493
workspace/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
workspace/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
workspace/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
@@ -3084,8 +2848,7 @@ checksums:
workspace/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
workspace/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
workspace/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
workspace/surveys/edit/survey_will_be_closed_at_midnight_cet: d0a6307e352b3b54fb30d6166201db1f
workspace/surveys/edit/survey_will_be_published_at_midnight_cet: d6906ffe76f24b13dab15c50bc24e1a7
workspace/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
workspace/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
workspace/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
workspace/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
@@ -3093,11 +2856,9 @@ checksums:
workspace/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: e45beba7ae126775f4966776c982a3b4
workspace/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
workspace/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
workspace/surveys/edit/this_will_remove_the_language_and_all_its_translations: 6a71ae70abbd61f13f15323d825a47f6
workspace/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
workspace/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
workspace/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
workspace/surveys/edit/translated: 5b9d805410310b726f12bacb06da44e3
workspace/surveys/edit/trigger_survey_when_one_of_the_actions_is_fired: 8570291668ec9879d204f10e861112db
workspace/surveys/edit/try_lollipop_or_mountain: c550a0f07b3ae40a237e30a4314a249c
workspace/surveys/edit/type_field_id: 714b845806236bb8a9d6a09933b836e9
@@ -3170,7 +2931,6 @@ checksums:
workspace/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
workspace/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
workspace/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
workspace/surveys/edit/visible: 54ea1310fe55664c24a712eb17070fbd
workspace/surveys/edit/wait: 014d18ade977bf08d75b995076596708
workspace/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
workspace/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
@@ -3346,8 +3106,6 @@ checksums:
workspace/surveys/summary/configure_alerts: 05cc642cd9398034d7e68589d22d97cf
workspace/surveys/summary/congrats: 378f06fe96289e527153f8201088ff74
workspace/surveys/summary/connect_your_website_or_app_with_formbricks_to_get_started: d24183c86d08b16d58daa8ad887b2837
workspace/surveys/summary/csat_satisfied: 4d6121afdc705a70465a230d6d1f6217
workspace/surveys/summary/csat_satisfied_tooltip: 3a69a76559a40fbbdff14525d83b459c
workspace/surveys/summary/current_count: 6a3e59de8559e88e991e0aeafa9cfeec
workspace/surveys/summary/custom_range: 9bc7e02a890644b13b5c0b0bdd96c165
workspace/surveys/summary/delete_all_existing_responses_and_displays: e346bcbdb1e0dfbce5925e19fdf0cc78
@@ -3355,7 +3113,7 @@ checksums:
workspace/surveys/summary/downloading_qr_code: 3c46bf636e617848a4fca9b6c5b51dac
workspace/surveys/summary/drop_offs: 605ee950f82110132d6c5780926af109
workspace/surveys/summary/drop_offs_tooltip: 2a01683380be45f17636365886cf3452
workspace/surveys/summary/effort_score: b79157d02a8ead85459c158272951ab5
workspace/surveys/summary/failed_to_copy_link: 4e891c757c80e770674e8e74d1c08487
workspace/surveys/summary/filter_added_successfully: e247f65020cd87454bcec0da6f0fd034
workspace/surveys/summary/filter_updated_successfully: 01146bc7e6394e271836be2f1b3a257b
workspace/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
@@ -3405,7 +3163,6 @@ checksums:
workspace/surveys/summary/limit: 347051f1a068e01e8c4e4f6744d8e727
workspace/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
workspace/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
workspace/surveys/summary/nps_promoters_tooltip: dea6a683c0c36189e325656d5a7596b8
workspace/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
workspace/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
workspace/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
@@ -3418,6 +3175,7 @@ checksums:
workspace/surveys/summary/quotas_completed_tooltip: ec5c4dc67eda27c06764354f695db613
workspace/surveys/summary/reset_survey: 8c88ddb81f5f787d183d2e7cb43e7c64
workspace/surveys/summary/reset_survey_warning: 6b44be171d7e2716f234387b100b173d
workspace/surveys/summary/satisfied: 4d542ba354b85e644acbca5691d2ce45
workspace/surveys/summary/selected_responses_csv: 9cef3faccd54d4f24647791e6359db90
workspace/surveys/summary/selected_responses_excel: a0ade8b2658e887a4a3f2ad3bdb0c686
workspace/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
@@ -3427,7 +3185,6 @@ checksums:
workspace/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
workspace/surveys/summary/survey_reset_successfully: f53db36a28980ef4766215cf13f01e51
workspace/surveys/summary/survey_results: b7d86f636beaee2b4d5746bdda058d07
workspace/surveys/summary/survey_scheduled_successfully: b5f39d8dc0c203466a0ffb5fa60163e8
workspace/surveys/summary/this_month: 50845a38865204a97773c44dcd2ebb90
workspace/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
workspace/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
@@ -3441,6 +3198,7 @@ checksums:
workspace/surveys/summary/youre_not_plugged_in_yet: f19da3cd474b9a3cf28e956fd811fb00
workspace/surveys/survey_deleted_successfully: a6b654cc914b344a4475fd2fd4a98cc5
workspace/surveys/survey_duplicated_successfully: 91e244f1e7a33640bb4817166a01ff46
workspace/surveys/survey_duplication_error: 35994330aed844ce37d8b4f09df24581
workspace/surveys/templates/all_channels: 6be67a82fc7326dc2304b23ab3348b87
workspace/surveys/templates/all_industries: c7354412fe34585526ff2232aadace41
workspace/surveys/templates/all_roles: 6582ccd0a2349c162a7ae1574cdf76be
@@ -3467,21 +3225,16 @@ checksums:
workspace/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
workspace/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
workspace/teams/team_settings_description: 52f91883b9ceb6de83efbf8efd4f11c0
workspace/unify/add_feedback_record: 19cf2b1fef0ca1400f2400e7ee681ea0
workspace/unify/add_feedback_record_description: 94bca46246ba7353049b33742554b4c0
workspace/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45
workspace/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e
workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
workspace/unify/api_ingestion: a14642d27bbb6843f9f4903b6555dfbb
workspace/unify/api_ingestion_manage_api_keys: 116786a004fb7b16ead8a5b7a6a2debe
workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3
workspace/unify/auto_generated: 6e83e8febd63275692c444cb8074531d
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
workspace/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
workspace/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
workspace/unify/collected_at: b41902ddb4586ba4a4611d726b5014aa
workspace/unify/configure_import: 71d550661f7e9fe322b60e7e870aa2fd
workspace/unify/configure_mapping: c794411c50bc511f8fc332def0e4e2f9
workspace/unify/connection: 421e709602c92ffbe04a266f6a092089
workspace/unify/connector_created_successfully: ea927316021fb2a41cc69ca3ec89d0aa
workspace/unify/connector_deleted_successfully: ea3c9842c5b8f75b02ecb9c80c74d780
workspace/unify/connector_duplicated_successfully: eb21ce42cdbef5fa38244206bf65fe4e
@@ -3500,12 +3253,9 @@ checksums:
workspace/unify/csv_import_duplicate_warning: 56625e4613b93690e95661e5faaa4b27
workspace/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6
workspace/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
workspace/unify/custom_source_type: d931a8a74d3a5becd568e398107979da
workspace/unify/custom_source_type_placeholder: f139e3e5d70dbf426d7c6b5ab2b198cc
workspace/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941
workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e
workspace/unify/deselect_all: facf8871b2e84a454c6bfe40c2821922
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
workspace/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
workspace/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9
@@ -3515,64 +3265,47 @@ checksums:
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
workspace/unify/feedback_record_created_successfully: 0ff30472085f1313a5ad53837c83e7c1
workspace/unify/feedback_record_details: 823f3353db049a9d263ef31405054cda
workspace/unify/feedback_record_details_description: 0b6f908154161241ce6bdeb4a2acaecd
workspace/unify/feedback_record_directory: 89a08a540d1c6eb9f0b1a4b8f56e8aca
workspace/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
workspace/unify/feedback_record_mcp: cdddbef2944489820fd7f376a49c2803
workspace/unify/feedback_record_updated_successfully: cb40ef4b924e21fa627ebe6809d1d826
workspace/unify/feedback_record_value_required: b54d4d86f82071a93dc979e8eb359cf0
workspace/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388
workspace/unify/feedback_records_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1
workspace/unify/feedback_sources: e58ec9be19db8789e7096a756d24f2b2
workspace/unify/feedback_sources_directory_access_multiple: 11d613bc1e9825aa6faa3db17ae678eb
workspace/unify/feedback_sources_directory_access_single: c9da6b30d410a0ca6302a00a5747dc19
workspace/unify/feedback_sources_settings_description: 45f162f2f81cd195c23cb3ec490bb3df
workspace/unify/field_group_id: 17024bb46ff1e088afb6a279dc85aad4
workspace/unify/field_group_label: 3df09c3b6fd22310359cf955ecff5c8e
workspace/unify/field_id: 7791b5d581b7a525dcadf11ec73c6ab7
workspace/unify/field_label: 6384505ca0e40010c666b712511132a6
workspace/unify/field_type: 2581066dc304c853a4a817c20996fa08
workspace/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a
workspace/unify/frd_cannot_be_changed: 265c12529f540d8309811f4e0090272f
workspace/unify/go_to_feedback_record_directories: 16b66b62f85e7be311778f39315d118a
workspace/unify/historical_import_complete: f46f98bf4db63bf2993bfb234dc95f62
workspace/unify/import_csv_data: f05e1d1ed88d528256efe5702df46646
workspace/unify/import_feedback: f05e1d1ed88d528256efe5702df46646
workspace/unify/import_historical_responses: d7941f65344b6bfba56a40cc53a063b4
workspace/unify/import_historical_responses_description: c860f7c6dbe8b74383ecf9cae9c219a0
workspace/unify/import_rows: d2963498a7d2766264c4d67db677e8ff
workspace/unify/import_via_source_name: eae32ae2fc87f925ca016fe8283bcbfd
workspace/unify/importing_data: a6d4478379a0faee05cd2c10ffe74984
workspace/unify/importing_historical_data: f5be578704ec26dc4ec573309e9fff20
workspace/unify/invalid_enum_values: e6ca8740dab72f64e8dc5780b5cffcc6
workspace/unify/invalid_values_found: 5011dc9c0294a222033f9910ea919b8a
workspace/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e
workspace/unify/manage_directories: 460e00e1cbf1f51de57a2548546e33d7
workspace/unify/manage_feedback_sources: 6aa6a82334ab680b5aa187b7245e8ec8
workspace/unify/metadata: 695d4f7da261ba76e3be4de495491028
workspace/unify/metadata_key: c478d228673f59fa556208ece60452f6
workspace/unify/metadata_read_only_entries: 1934fee46c0a117f4926b61cc3d2d602
workspace/unify/metadata_value: 8d69be1f5a20d9473a33c35670dff216
workspace/unify/missing_feedback_source_title: 9ab1b8d54b4da72dd00ce03fe3b698b5
workspace/unify/n_supported_questions: d75413d386441b5eb137a1ea191e4bd9
workspace/unify/no_feedback_record_directory_available: b8126ef5d6276d9655a9b27ffcaca824
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
workspace/unify/no_surveys_found: 649a2f29b4c34525778d9177605fb326
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
workspace/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
workspace/unify/question_selected: b9ff13b6212874258da911867932dc7d
workspace/unify/question_type_not_supported: 8d9f7554e3b509dfd5307d8d1fef08d7
workspace/unify/questions_selected: 1f13d6fecafa2ce5ea9e6d07078a1d38
workspace/unify/records_will_go_to: 6a3f5a6580857a931bab389ad354831c
workspace/unify/refresh_feedback_records: c111751e02a7dee57390ed7fb79cfcc6
workspace/unify/refreshing_feedback_records: 2a03b44510ebe19eea6473639e9a7222
workspace/unify/request_feedback_source: 51045caa2c81dee971d23a1841d19a7e
workspace/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
workspace/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
workspace/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20
workspace/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862
workspace/unify/select_all: eedc7cdb02de467c15dc418a066a77f2
workspace/unify/select_feedback_record_directory: 88afbf2c2a322249908ee5d00ec5f65d
workspace/unify/select_feedback_record_source_type: 10997fcbea2f93e756888cf7a7476fdf
workspace/unify/select_questions: 13c79b8c284423eb6140534bf2137e56
workspace/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc
workspace/unify/select_source_type_prompt: c3fce7d908ee62b9e1b7fab1b17606d7
workspace/unify/select_survey: bac52e59c7847417bef6fe7b7096b475
workspace/unify/select_survey_and_questions: 53914988a2f48caecea23f3b3b868b9f
workspace/unify/select_survey_questions_description: 3386ed56085eabebefa3cc453269fc5b
@@ -3582,20 +3315,20 @@ checksums:
workspace/unify/showing_rows: 83d3440314d1e6f2721e034369a3a131
workspace/unify/source: 45309626f464f4bda161ee783a4c8c80
workspace/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746
workspace/unify/source_connect_feedback_record_mcp_description: a3f56e2a6e403f4021e83f1b1a466d95
workspace/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
workspace/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
workspace/unify/source_id: 134a9a7d473508c5623ac724a5ba4be9
workspace/unify/source_name: 157675beca12efcd8ec512c5256b1a61
workspace/unify/source_type: d1ff69af76c687eb189db72030717570
workspace/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
workspace/unify/sources: ecbbe6e49baa335c5afd7b04b609d006
workspace/unify/status_active: 3de9afebcb9d4ce8ac42e14995f79ffd
workspace/unify/status_completed: 0e4bbce9985f25eb673d9a054c8d5334
workspace/unify/status_draft: e8a92958ad300aacfe46c2bf6644927e
workspace/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248
workspace/unify/status_live_sync: 7e794257419414f57d34845ef38d0939
workspace/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
workspace/unify/status_ready: 437c0eea608e15ad5cdab94bde2f4b48
workspace/unify/submission_id: 02edf76883b47079dbe20f3f36b7c1a7
workspace/unify/survey_has_no_questions: c08514b6bce5eb464a4492239be5934d
workspace/unify/topics_and_subtopics: 1148eca01a1993fadca932efcdea7641
workspace/unify/survey_import_line: 63fa0ea1d7daa3ba333436fbc65f8b19
workspace/unify/total_feedback_records: 8962087650b62e4a12b81e7d09317ffa
workspace/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
workspace/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
workspace/unify/updated_at: 8fdb85248e591254973403755dcc3724
@@ -3603,10 +3336,6 @@ checksums:
workspace/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
workspace/unify/user_identifier: 61073457a5c3901084b557d065f876be
workspace/unify/value: 34b0eaa85808b15cbc4be94c64d0146b
workspace/unify/value_boolean: bbdcd3f46954b6304b9069e94e1371ab
workspace/unify/value_date: c8d705d1975affc01c002324725fec3f
workspace/unify/value_number: 1f14da79d14bd7b1c2324141f4470675
workspace/unify/value_text: e097a597cc507c716401ad18255de578
workspace/xm-templates/ces: e2ea309b2f7f13257967b966c2fda1e9
workspace/xm-templates/ces_description: c8d9794dd17d5ab85a979f1b3e1bc935
workspace/xm-templates/csat: fdfc1dc6214cce661dcdc32a71d80337
-370
View File
@@ -1,370 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const mockStartJobsRuntime = vi.fn();
const mockRemoveRecurringSurveySchedulingJobSchedule = vi.fn();
const mockUpsertRecurringSurveySchedulingJobSchedule = vi.fn();
const mockDebug = vi.fn();
const mockError = vi.fn();
const mockWarn = vi.fn();
const mockGetJobsQueueingConfig = vi.fn();
const mockGetJobsWorkerBootstrapConfig = vi.fn();
const mockProcessResponsePipelineJob = vi.fn();
const mockProcessSurveySchedulingJob = vi.fn();
const TEST_TIMEOUT_MS = 15_000;
const slowTest = (name: string, fn: () => Promise<void>): void => {
test(name, fn, TEST_TIMEOUT_MS);
};
vi.mock("@formbricks/jobs", () => ({
removeRecurringSurveySchedulingJobSchedule: mockRemoveRecurringSurveySchedulingJobSchedule,
startJobsRuntime: mockStartJobsRuntime,
upsertRecurringSurveySchedulingJobSchedule: mockUpsertRecurringSurveySchedulingJobSchedule,
}));
vi.mock("@/lib/jobs/config", () => ({
getJobsQueueingConfig: mockGetJobsQueueingConfig,
getJobsWorkerBootstrapConfig: mockGetJobsWorkerBootstrapConfig,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
debug: mockDebug,
error: mockError,
info: vi.fn(),
warn: mockWarn,
},
}));
vi.mock("@/modules/response-pipeline/lib/process-response-pipeline-job", () => ({
processResponsePipelineJob: mockProcessResponsePipelineJob,
}));
vi.mock("@/modules/survey/scheduling/lib/process-survey-scheduling-job", () => ({
processSurveySchedulingJob: mockProcessSurveySchedulingJob,
}));
describe("instrumentation-jobs", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.useFakeTimers();
mockRemoveRecurringSurveySchedulingJobSchedule.mockResolvedValue(true);
mockGetJobsQueueingConfig.mockReturnValue({
enabled: false,
redisUrl: null,
});
});
afterEach(async () => {
const { resetJobsWorkerRegistrationForTests } = await import("./instrumentation-jobs");
await resetJobsWorkerRegistrationForTests();
vi.useRealTimers();
});
slowTest("skips worker startup when disabled", async () => {
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: false,
runtimeOptions: null,
});
const { registerJobsWorker } = await import("./instrumentation-jobs");
const result = await registerJobsWorker();
expect(result).toBeNull();
expect(mockStartJobsRuntime).not.toHaveBeenCalled();
expect(mockUpsertRecurringSurveySchedulingJobSchedule).not.toHaveBeenCalled();
expect(mockDebug).toHaveBeenCalledWith("BullMQ worker startup skipped");
});
slowTest("starts the worker once and registers handlers", async () => {
const mockRuntime = {
close: vi.fn().mockResolvedValue(undefined),
};
const mockExistingOverride = vi.fn();
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 4,
jobHandlerOverrides: {
"test-log.process": mockExistingOverride,
},
redisUrl: "redis://localhost:6379",
workerCount: 2,
},
});
mockStartJobsRuntime.mockResolvedValue(mockRuntime);
const { registerJobsWorker } = await import("./instrumentation-jobs");
const first = await registerJobsWorker();
const second = await registerJobsWorker();
expect(first).toBe(mockRuntime);
expect(second).toBe(mockRuntime);
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(1);
expect(mockStartJobsRuntime).toHaveBeenCalledWith({
concurrency: 4,
jobHandlerOverrides: {
"response-pipeline.process": expect.any(Function),
"survey-scheduling.reconcile": expect.any(Function),
"test-log.process": mockExistingOverride,
},
redisUrl: "redis://localhost:6379",
workerCount: 2,
});
const overrides = mockStartJobsRuntime.mock.calls[0]?.[0]?.jobHandlerOverrides;
const responsePipelineOverride = overrides?.["response-pipeline.process"];
const surveySchedulingOverride = overrides?.["survey-scheduling.reconcile"];
await responsePipelineOverride?.(
{
workspaceId: "ws_123",
event: "responseCreated",
response: { id: "res_123" },
surveyId: "survey_123",
},
{
attempt: 1,
jobId: "job_123",
jobName: "response-pipeline.process",
maxAttempts: 3,
queueName: "background-jobs",
}
);
await surveySchedulingOverride?.(
{
scope: "global",
},
{
attempt: 1,
jobId: "job_456",
jobName: "survey-scheduling.reconcile",
maxAttempts: 3,
queueName: "background-jobs",
}
);
expect(mockProcessResponsePipelineJob).toHaveBeenCalledWith(
{
workspaceId: "ws_123",
event: "responseCreated",
response: { id: "res_123" },
surveyId: "survey_123",
},
{
attempt: 1,
jobId: "job_123",
jobName: "response-pipeline.process",
maxAttempts: 3,
queueName: "background-jobs",
}
);
expect(mockProcessSurveySchedulingJob).toHaveBeenCalledWith(
{
scope: "global",
},
{
attempt: 1,
jobId: "job_456",
jobName: "survey-scheduling.reconcile",
maxAttempts: 3,
queueName: "background-jobs",
}
);
});
slowTest("reuses the in-flight startup promise", async () => {
const mockRuntime = {
close: vi.fn().mockResolvedValue(undefined),
};
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 2,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
let resolveRuntime: ((value: typeof mockRuntime) => void) | undefined;
mockStartJobsRuntime.mockReturnValue(
new Promise((resolve) => {
resolveRuntime = resolve;
})
);
const { registerJobsWorker } = await import("./instrumentation-jobs");
const firstPromise = registerJobsWorker();
const secondPromise = registerJobsWorker();
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(1);
resolveRuntime?.(mockRuntime);
await expect(firstPromise).resolves.toBe(mockRuntime);
await expect(secondPromise).resolves.toBe(mockRuntime);
});
slowTest("logs and rethrows startup failures", async () => {
const startupError = new Error("startup failed");
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 1,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
mockStartJobsRuntime.mockRejectedValue(startupError);
const { registerJobsWorker } = await import("./instrumentation-jobs");
await expect(registerJobsWorker()).rejects.toThrow("startup failed");
expect(mockError).toHaveBeenCalledWith({ err: startupError }, "BullMQ worker registration failed");
expect(mockWarn).toHaveBeenCalledWith(
{ retryDelayMs: 30_000 },
"BullMQ worker registration retry scheduled"
);
});
slowTest("retries worker startup after a transient failure", async () => {
const startupError = new Error("startup failed");
const recoveredRuntime = {
close: vi.fn().mockResolvedValue(undefined),
};
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 1,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
mockStartJobsRuntime.mockRejectedValueOnce(startupError).mockResolvedValueOnce(recoveredRuntime);
const { registerJobsWorker } = await import("./instrumentation-jobs");
await expect(registerJobsWorker()).rejects.toThrow("startup failed");
await vi.advanceTimersByTimeAsync(30_000);
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(2);
await expect(registerJobsWorker()).resolves.toBe(recoveredRuntime);
});
slowTest(
"registers recurring schedules once when queueing is enabled without an in-process worker",
async () => {
mockGetJobsQueueingConfig.mockReturnValue({
enabled: true,
redisUrl: "redis://localhost:6379",
});
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: false,
runtimeOptions: null,
});
mockUpsertRecurringSurveySchedulingJobSchedule.mockResolvedValue({
id: "schedule-job-1",
name: "survey-scheduling.reconcile",
queueName: "background-jobs",
});
const { registerRecurringJobs } = await import("./instrumentation-jobs");
const { SURVEY_SCHEDULING_DAILY_CRON_PATTERN, SURVEY_SCHEDULING_TIME_ZONE } =
await import("@/modules/survey/scheduling/lib/constants");
await registerRecurringJobs();
await registerRecurringJobs();
expect(mockStartJobsRuntime).not.toHaveBeenCalled();
expect(mockRemoveRecurringSurveySchedulingJobSchedule).toHaveBeenCalledTimes(1);
expect(mockRemoveRecurringSurveySchedulingJobSchedule).toHaveBeenCalledWith({
scheduleId: "daily-survey-scheduling",
scope: "global",
});
expect(mockUpsertRecurringSurveySchedulingJobSchedule).toHaveBeenCalledTimes(1);
expect(mockUpsertRecurringSurveySchedulingJobSchedule).toHaveBeenCalledWith(
{
scheduleId: "daily-survey-scheduling",
scope: "global",
},
{
cronPattern: SURVEY_SCHEDULING_DAILY_CRON_PATTERN,
kind: "cron",
timeZone: SURVEY_SCHEDULING_TIME_ZONE,
},
{
scope: "global",
}
);
}
);
slowTest("retries recurring schedule registration after a transient failure", async () => {
const scheduleError = new Error("schedule failed");
mockGetJobsQueueingConfig.mockReturnValue({
enabled: true,
redisUrl: "redis://localhost:6379",
});
mockUpsertRecurringSurveySchedulingJobSchedule
.mockRejectedValueOnce(scheduleError)
.mockResolvedValueOnce({
id: "schedule-job-1",
name: "survey-scheduling.reconcile",
queueName: "background-jobs",
});
const { registerRecurringJobs } = await import("./instrumentation-jobs");
await expect(registerRecurringJobs()).rejects.toThrow("schedule failed");
expect(mockError).toHaveBeenCalledWith(
{ err: scheduleError },
"BullMQ recurring job registration failed"
);
expect(mockWarn).toHaveBeenCalledWith(
{ retryDelayMs: 30_000 },
"BullMQ recurring job registration retry scheduled"
);
await vi.advanceTimersByTimeAsync(30_000);
expect(mockUpsertRecurringSurveySchedulingJobSchedule).toHaveBeenCalledTimes(2);
});
slowTest("clears registration state even when reset close fails", async () => {
const failingRuntime = {
close: vi.fn().mockRejectedValue(new Error("close failed")),
};
const nextRuntime = {
close: vi.fn().mockResolvedValue(undefined),
};
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 1,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
mockStartJobsRuntime.mockResolvedValueOnce(failingRuntime).mockResolvedValueOnce(nextRuntime);
const { registerJobsWorker, resetJobsWorkerRegistrationForTests } =
await import("./instrumentation-jobs");
await expect(registerJobsWorker()).resolves.toBe(failingRuntime);
await expect(resetJobsWorkerRegistrationForTests()).resolves.toBeUndefined();
await expect(registerJobsWorker()).resolves.toBe(nextRuntime);
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(2);
expect(mockError).toHaveBeenCalledWith(
{ err: expect.any(Error) },
"BullMQ worker test reset close failed"
);
});
});
-242
View File
@@ -1,242 +0,0 @@
import {
type JobHandlerOverrides,
type JobsRuntimeHandle,
type TResponsePipelineJobData,
type TSurveySchedulingJobData,
removeRecurringSurveySchedulingJobSchedule,
startJobsRuntime,
upsertRecurringSurveySchedulingJobSchedule,
} from "@formbricks/jobs";
import { logger } from "@formbricks/logger";
import { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } from "@/lib/jobs/config";
import { processResponsePipelineJob } from "@/modules/response-pipeline/lib/process-response-pipeline-job";
import {
SURVEY_SCHEDULING_DAILY_CRON_PATTERN,
SURVEY_SCHEDULING_DAILY_SCHEDULE_ID,
SURVEY_SCHEDULING_GLOBAL_SCOPE,
SURVEY_SCHEDULING_TIME_ZONE,
} from "@/modules/survey/scheduling/lib/constants";
import { processSurveySchedulingJob } from "@/modules/survey/scheduling/lib/process-survey-scheduling-job";
const WORKER_STARTUP_RETRY_DELAY_MS = 30_000;
type TJobsRuntimeGlobal = typeof globalThis & {
formbricksJobsRecurringRegistration: Promise<void> | undefined;
formbricksJobsRecurringRegistered: boolean | undefined;
formbricksJobsRecurringRetryTimeout: ReturnType<typeof setTimeout> | undefined;
formbricksJobsRuntime: JobsRuntimeHandle | undefined;
formbricksJobsRuntimeInitializing: Promise<JobsRuntimeHandle> | undefined;
formbricksJobsRuntimeRetryTimeout: ReturnType<typeof setTimeout> | undefined;
};
const globalForJobsRuntime = globalThis as TJobsRuntimeGlobal;
const RESPONSE_PIPELINE_JOB_NAME = "response-pipeline.process";
const SURVEY_SCHEDULING_JOB_NAME = "survey-scheduling.reconcile";
const responsePipelineJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processResponsePipelineJob(data as TResponsePipelineJobData, context);
};
const surveySchedulingJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processSurveySchedulingJob(data as TSurveySchedulingJobData, context);
};
const registerSurveySchedulingSchedule = async (): Promise<void> => {
await removeRecurringSurveySchedulingJobSchedule({
scheduleId: SURVEY_SCHEDULING_DAILY_SCHEDULE_ID,
scope: SURVEY_SCHEDULING_GLOBAL_SCOPE,
});
await upsertRecurringSurveySchedulingJobSchedule(
{
scheduleId: SURVEY_SCHEDULING_DAILY_SCHEDULE_ID,
scope: SURVEY_SCHEDULING_GLOBAL_SCOPE,
},
{
cronPattern: SURVEY_SCHEDULING_DAILY_CRON_PATTERN,
kind: "cron",
timeZone: SURVEY_SCHEDULING_TIME_ZONE,
},
{
scope: SURVEY_SCHEDULING_GLOBAL_SCOPE,
}
);
};
const clearRecurringJobsRetryTimeout = (): void => {
if (globalForJobsRuntime.formbricksJobsRecurringRetryTimeout) {
clearTimeout(globalForJobsRuntime.formbricksJobsRecurringRetryTimeout);
globalForJobsRuntime.formbricksJobsRecurringRetryTimeout = undefined;
}
};
const scheduleRecurringJobsRetry = (): void => {
if (
globalForJobsRuntime.formbricksJobsRecurringRegistered ||
globalForJobsRuntime.formbricksJobsRecurringRegistration ||
globalForJobsRuntime.formbricksJobsRecurringRetryTimeout
) {
return;
}
globalForJobsRuntime.formbricksJobsRecurringRetryTimeout = setTimeout(() => {
globalForJobsRuntime.formbricksJobsRecurringRetryTimeout = undefined;
void registerRecurringJobs().catch(() => undefined);
}, WORKER_STARTUP_RETRY_DELAY_MS);
logger.warn(
{ retryDelayMs: WORKER_STARTUP_RETRY_DELAY_MS },
"BullMQ recurring job registration retry scheduled"
);
};
const clearJobsWorkerRetryTimeout = (): void => {
if (globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout) {
clearTimeout(globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout);
globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout = undefined;
}
};
const scheduleJobsWorkerRetry = (): void => {
if (
globalForJobsRuntime.formbricksJobsRuntime ||
globalForJobsRuntime.formbricksJobsRuntimeInitializing ||
globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout
) {
return;
}
globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout = setTimeout(() => {
globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout = undefined;
void registerJobsWorker().catch(() => undefined);
}, WORKER_STARTUP_RETRY_DELAY_MS);
logger.warn({ retryDelayMs: WORKER_STARTUP_RETRY_DELAY_MS }, "BullMQ worker registration retry scheduled");
};
export const registerRecurringJobs = async (): Promise<void> => {
const jobsQueueingConfig = getJobsQueueingConfig();
if (!jobsQueueingConfig.enabled || !jobsQueueingConfig.redisUrl) {
clearRecurringJobsRetryTimeout();
logger.debug("BullMQ recurring job registration skipped");
return;
}
if (globalForJobsRuntime.formbricksJobsRecurringRegistered) {
return;
}
if (globalForJobsRuntime.formbricksJobsRecurringRegistration) {
return await globalForJobsRuntime.formbricksJobsRecurringRegistration;
}
globalForJobsRuntime.formbricksJobsRecurringRegistration = (async () => {
await registerSurveySchedulingSchedule();
clearRecurringJobsRetryTimeout();
globalForJobsRuntime.formbricksJobsRecurringRegistered = true;
globalForJobsRuntime.formbricksJobsRecurringRegistration = undefined;
})();
try {
return await globalForJobsRuntime.formbricksJobsRecurringRegistration;
} catch (error) {
globalForJobsRuntime.formbricksJobsRecurringRegistration = undefined;
logger.error({ err: error }, "BullMQ recurring job registration failed");
scheduleRecurringJobsRetry();
throw error;
}
};
export const registerJobsWorker = async (): Promise<JobsRuntimeHandle | null> => {
const jobsWorkerBootstrapConfig = getJobsWorkerBootstrapConfig();
if (!jobsWorkerBootstrapConfig.enabled || !jobsWorkerBootstrapConfig.runtimeOptions) {
clearJobsWorkerRetryTimeout();
logger.debug("BullMQ worker startup skipped");
return null;
}
if (globalForJobsRuntime.formbricksJobsRuntime) {
return globalForJobsRuntime.formbricksJobsRuntime;
}
if (globalForJobsRuntime.formbricksJobsRuntimeInitializing) {
return await globalForJobsRuntime.formbricksJobsRuntimeInitializing;
}
const runtimeOptions = jobsWorkerBootstrapConfig.runtimeOptions;
const jobHandlerOverrides: JobHandlerOverrides = runtimeOptions.jobHandlerOverrides
? {
...runtimeOptions.jobHandlerOverrides,
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
}
: {
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
};
globalForJobsRuntime.formbricksJobsRuntimeInitializing = (async () => {
const runtime = await startJobsRuntime({
...runtimeOptions,
jobHandlerOverrides,
});
clearJobsWorkerRetryTimeout();
globalForJobsRuntime.formbricksJobsRuntime = runtime;
globalForJobsRuntime.formbricksJobsRuntimeInitializing = undefined;
return runtime;
})();
try {
return await globalForJobsRuntime.formbricksJobsRuntimeInitializing;
} catch (error) {
globalForJobsRuntime.formbricksJobsRuntimeInitializing = undefined;
logger.error({ err: error }, "BullMQ worker registration failed");
scheduleJobsWorkerRetry();
throw error;
}
};
export const resetJobsWorkerRegistrationForTests = async (): Promise<void> => {
const runtime = globalForJobsRuntime.formbricksJobsRuntime;
const initializing = globalForJobsRuntime.formbricksJobsRuntimeInitializing;
clearRecurringJobsRetryTimeout();
clearJobsWorkerRetryTimeout();
globalForJobsRuntime.formbricksJobsRecurringRegistered = undefined;
globalForJobsRuntime.formbricksJobsRecurringRegistration = undefined;
globalForJobsRuntime.formbricksJobsRuntime = undefined;
globalForJobsRuntime.formbricksJobsRuntimeInitializing = undefined;
const runtimesToClose = new Set<JobsRuntimeHandle>();
if (runtime) {
runtimesToClose.add(runtime);
}
if (initializing) {
try {
const initializedRuntime = await initializing;
runtimesToClose.add(initializedRuntime);
} catch {
// Startup failures are already surfaced by the test that triggered them.
}
}
if (globalForJobsRuntime.formbricksJobsRuntime) {
runtimesToClose.add(globalForJobsRuntime.formbricksJobsRuntime);
}
globalForJobsRuntime.formbricksJobsRuntime = undefined;
globalForJobsRuntime.formbricksJobsRuntimeInitializing = undefined;
await Promise.all(
[...runtimesToClose].map(async (runtimeHandle) => {
try {
await runtimeHandle.close();
} catch (error) {
logger.error({ err: error }, "BullMQ worker test reset close failed");
}
})
);
};
-52
View File
@@ -1,52 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mockRegisterJobsWorker = vi.fn();
const mockRegisterRecurringJobs = vi.fn();
vi.mock("@sentry/nextjs", () => ({
captureRequestError: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
PROMETHEUS_ENABLED: false,
SENTRY_DSN: undefined,
}));
vi.mock("./instrumentation-jobs", () => ({
registerRecurringJobs: mockRegisterRecurringJobs,
registerJobsWorker: mockRegisterJobsWorker,
}));
describe("instrumentation register", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
process.env.NEXT_RUNTIME = "nodejs";
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
});
test("does not block Next.js boot on BullMQ worker startup", async () => {
mockRegisterRecurringJobs.mockReturnValue(new Promise(() => undefined));
mockRegisterJobsWorker.mockReturnValue(new Promise(() => undefined));
const { register } = await import("./instrumentation");
await expect(register()).resolves.toBeUndefined();
expect(mockRegisterRecurringJobs).toHaveBeenCalledTimes(1);
expect(mockRegisterJobsWorker).toHaveBeenCalledTimes(1);
});
test("swallows BullMQ worker startup rejections after triggering background registration", async () => {
mockRegisterRecurringJobs.mockRejectedValue(new Error("schedule failed"));
mockRegisterJobsWorker.mockRejectedValue(new Error("startup failed"));
const { register } = await import("./instrumentation");
await expect(register()).resolves.toBeUndefined();
await Promise.resolve();
expect(mockRegisterRecurringJobs).toHaveBeenCalledTimes(1);
expect(mockRegisterJobsWorker).toHaveBeenCalledTimes(1);
});
});
-20
View File
@@ -1,6 +1,5 @@
import * as Sentry from "@sentry/nextjs";
import { type Instrumentation } from "next";
import { logger } from "@formbricks/logger";
import { isExpectedError } from "@formbricks/types/errors";
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
@@ -22,25 +21,6 @@ export const register = async () => {
if (PROMETHEUS_ENABLED || process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
await import("./instrumentation-node");
}
// Skip runtime-only BullMQ bootstrapping during production builds.
// eslint-disable-next-line turbo/no-undeclared-env-vars -- NEXT_PHASE is a next.js env variable
if (process.env.NEXT_PHASE !== "phase-production-build") {
try {
const { registerJobsWorker, registerRecurringJobs } = await import("./instrumentation-jobs");
void registerRecurringJobs().catch((error: unknown) => {
logger.error(
{ err: error },
"BullMQ recurring job registration failed during Next.js instrumentation"
);
});
void registerJobsWorker().catch((error: unknown) => {
logger.error({ err: error }, "BullMQ worker registration failed during Next.js instrumentation");
});
} catch (error) {
logger.error({ err: error }, "BullMQ instrumentation import failed during Next.js instrumentation");
}
}
}
// Sentry init loads after OTEL to avoid TracerProvider conflicts
// Sentry tracing is disabled (tracesSampleRate: 0) -- SigNoz handles distributed tracing
+11 -11
View File
@@ -30,7 +30,7 @@ describe("ActionClass Service", () => {
});
describe("getActionClasses", () => {
test("should return action classes for workspace", async () => {
test("should return action classes for environment", async () => {
const mockActionClasses: TActionClass[] = [
{
id: "id1",
@@ -41,15 +41,15 @@ describe("ActionClass Service", () => {
type: "code",
key: "key1",
noCodeConfig: null,
workspaceId: "ws1",
workspaceId: "env1",
},
];
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
const result = await getActionClasses("ws1");
const result = await getActionClasses("env1");
expect(result).toEqual(mockActionClasses);
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: { workspaceId: "ws1" },
where: { workspaceId: "env1" },
select: expect.any(Object),
take: undefined,
skip: undefined,
@@ -59,7 +59,7 @@ describe("ActionClass Service", () => {
test("should throw DatabaseError when prisma throws", async () => {
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("fail"));
await expect(getActionClasses("ws1")).rejects.toThrow(DatabaseError);
await expect(getActionClasses("env1")).rejects.toThrow(DatabaseError);
});
});
@@ -78,15 +78,15 @@ describe("ActionClass Service", () => {
elementSelector: { cssSelector: "button" },
urlFilters: [],
},
workspaceId: "ws2",
workspaceId: "env2",
};
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(mockActionClass);
const result = await getActionClassByWorkspaceIdAndName("ws2", "Action 2");
const result = await getActionClassByWorkspaceIdAndName("env2", "Action 2");
expect(result).toEqual(mockActionClass);
expect(prisma.actionClass.findFirst).toHaveBeenCalledWith({
where: { name: "Action 2", workspaceId: "ws2" },
where: { name: "Action 2", workspaceId: "env2" },
select: expect.any(Object),
});
});
@@ -94,14 +94,14 @@ describe("ActionClass Service", () => {
test("should return null when not found", async () => {
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(null);
const result = await getActionClassByWorkspaceIdAndName("ws2", "Action 2");
const result = await getActionClassByWorkspaceIdAndName("env2", "Action 2");
expect(result).toBeNull();
});
test("should throw DatabaseError when prisma throws", async () => {
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockRejectedValue(new Error("fail"));
await expect(getActionClassByWorkspaceIdAndName("ws2", "Action 2")).rejects.toThrow(DatabaseError);
await expect(getActionClassByWorkspaceIdAndName("env2", "Action 2")).rejects.toThrow(DatabaseError);
});
});
@@ -116,7 +116,7 @@ describe("ActionClass Service", () => {
type: "code",
key: "key3",
noCodeConfig: null,
workspaceId: "ws3",
workspaceId: "env3",
};
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(mockActionClass);
+8
View File
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
getOrganization: vi.fn(),
getIsAIDataAnalysisEnabled: vi.fn(),
getIsAISmartToolsEnabled: vi.fn(),
getTranslate: vi.fn(),
loggerError: vi.fn(),
}));
@@ -65,6 +66,10 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsAISmartToolsEnabled: mocks.getIsAISmartToolsEnabled,
}));
vi.mock("@/lingodotdev/server", () => ({
getTranslate: mocks.getTranslate,
}));
describe("AI organization service", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -77,6 +82,9 @@ describe("AI organization service", () => {
});
mocks.getIsAISmartToolsEnabled.mockResolvedValue(true);
mocks.getIsAIDataAnalysisEnabled.mockResolvedValue(true);
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
values ? `${key}:${JSON.stringify(values)}` : key
);
});
test("returns the instance AI status and organization settings", async () => {
+12 -13
View File
@@ -4,17 +4,9 @@ import { logger } from "@formbricks/logger";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { env } from "@/lib/env";
import { getOrganization } from "@/lib/organization/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsAIDataAnalysisEnabled, getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
export const AI_ERROR_CODES = {
FEATURES_NOT_ENABLED: "ai_features_not_enabled",
SMART_TOOLS_DISABLED: "ai_smart_tools_disabled",
DATA_ANALYSIS_DISABLED: "ai_data_analysis_disabled",
INSTANCE_NOT_CONFIGURED: "ai_instance_not_configured",
} as const;
export type TAIErrorCode = (typeof AI_ERROR_CODES)[keyof typeof AI_ERROR_CODES];
export interface TOrganizationAIConfig {
organizationId: string;
isAISmartToolsEnabled: boolean;
@@ -52,24 +44,31 @@ export const assertOrganizationAIConfigured = async (
organizationId: string,
capability: "smartTools" | "dataAnalysis"
): Promise<TOrganizationAIConfig> => {
const t = await getTranslate();
const aiConfig = await getOrganizationAIConfig(organizationId);
const isCapabilityEntitled =
capability === "smartTools" ? aiConfig.isAISmartToolsEntitled : aiConfig.isAIDataAnalysisEntitled;
if (!isCapabilityEntitled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.FEATURES_NOT_ENABLED);
throw new OperationNotAllowedError(
t("workspace.settings.general.ai_features_not_enabled_for_organization")
);
}
if (capability === "smartTools" && !aiConfig.isAISmartToolsEnabled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.SMART_TOOLS_DISABLED);
throw new OperationNotAllowedError(
t("workspace.settings.general.ai_smart_tools_disabled_for_organization")
);
}
if (capability === "dataAnalysis" && !aiConfig.isAIDataAnalysisEnabled) {
throw new OperationNotAllowedError(AI_ERROR_CODES.DATA_ANALYSIS_DISABLED);
throw new OperationNotAllowedError(
t("workspace.settings.general.ai_data_analysis_disabled_for_organization")
);
}
if (!aiConfig.isInstanceConfigured) {
throw new OperationNotAllowedError(AI_ERROR_CODES.INSTANCE_NOT_CONFIGURED);
throw new OperationNotAllowedError(t("workspace.settings.general.ai_instance_not_configured"));
}
return aiConfig;
+18 -32
View File
@@ -11,8 +11,6 @@ import {
verifyPassword,
} from "./auth";
const PASSWORD_TEST_TIMEOUT_MS = 30_000;
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -23,38 +21,26 @@ vi.mock("@formbricks/database", () => ({
}));
describe("Password Management", () => {
test(
"hashPassword should hash a password",
async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
expect(hashedPassword).toBeDefined();
expect(hashedPassword).not.toBe(password);
},
PASSWORD_TEST_TIMEOUT_MS
);
test("hashPassword should hash a password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
expect(hashedPassword).toBeDefined();
expect(hashedPassword).not.toBe(password);
});
test(
"verifyPassword should verify a correct password",
async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
},
PASSWORD_TEST_TIMEOUT_MS
);
test("verifyPassword should verify a correct password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
});
test(
"verifyPassword should reject an incorrect password",
async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword("wrongPassword", hashedPassword);
expect(isValid).toBe(false);
},
PASSWORD_TEST_TIMEOUT_MS
);
test("verifyPassword should reject an incorrect password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword("wrongPassword", hashedPassword);
expect(isValid).toBe(false);
});
});
describe("Organization Access", () => {
+46 -62
View File
@@ -12,7 +12,7 @@ import {
ZConnectorUpdateInput,
getHubFieldTypeFromElementType,
} from "@formbricks/types/connector";
import { AuthorizationError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
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";
@@ -86,24 +86,20 @@ const resolveSurveyMappings = async (
const elements = getElementsFromBlocks(survey.blocks);
const elementMap = new Map(elements.map((el) => [el.id, el]));
return elementIds.flatMap((elementId) => {
const element = elementMap.get(elementId);
if (!element) {
return elementIds
.filter((elementId) => {
if (elementMap.has(elementId)) return true;
logger.warn({ surveyId, elementId }, "Skipping unknown elementId when building connector mappings");
return [];
}
const hubFieldType = getHubFieldTypeFromElementType(element.type);
if (!hubFieldType) {
logger.warn(
{ surveyId, elementId, elementType: element.type },
"Skipping unmappable element type when building connector mappings"
);
return [];
}
return [{ surveyId, elementId, hubFieldType }];
});
return false;
})
.map((elementId) => {
const element = elementMap.get(elementId)!;
return {
surveyId,
elementId,
hubFieldType: getHubFieldTypeFromElementType(element.type),
};
});
};
const resolveFormbricksMappingsInput = async (
@@ -112,12 +108,7 @@ const resolveFormbricksMappingsInput = async (
const allMappings = await Promise.all(
entries.map(({ surveyId, elementIds }) => resolveSurveyMappings(surveyId, elementIds))
);
const flattenedMappings = allMappings.flat();
if (flattenedMappings.length === 0) {
throw new InvalidInputError("No supported survey questions selected for connector mapping");
}
return { type: "formbricks_survey", mappings: flattenedMappings };
return { type: "formbricks_survey", mappings: allMappings.flat() };
};
const ZFormbricksSurveyMapping = z.object({
@@ -125,23 +116,15 @@ const ZFormbricksSurveyMapping = z.object({
elementIds: z.array(z.string()).min(1),
});
// Temporary compatibility to support legacy client payloads using `formbricks`.
const ZConnectorCreateInputWithLegacyType = ZConnectorCreateInput.extend({
type: z.enum(["formbricks_survey", "csv", "formbricks"]),
});
const ZCreateConnectorWithMappingsAction = z
.object({
workspaceId: ZId,
connectorInput: ZConnectorCreateInputWithLegacyType,
connectorInput: ZConnectorCreateInput,
formbricksMappings: z.array(ZFormbricksSurveyMapping).optional(),
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
})
.superRefine((data, ctx) => {
const normalizedType =
data.connectorInput.type === "formbricks" ? "formbricks_survey" : data.connectorInput.type;
if (normalizedType === "formbricks_survey") {
if (data.connectorInput.type === "formbricks_survey") {
if (!data.formbricksMappings?.length) {
ctx.addIssue({
code: "custom",
@@ -149,7 +132,7 @@ const ZCreateConnectorWithMappingsAction = z
message: "At least one survey mapping is required for Formbricks connectors",
});
}
} else if (normalizedType === "csv") {
} else if (data.connectorInput.type === "csv") {
if (!data.fieldMappings?.length) {
ctx.addIssue({
code: "custom",
@@ -163,14 +146,6 @@ const ZCreateConnectorWithMappingsAction = z
export const createConnectorWithMappingsAction = authenticatedActionClient
.inputSchema(ZCreateConnectorWithMappingsAction)
.action(async ({ ctx, parsedInput }): Promise<TConnectorWithMappings> => {
const connectorInput = ZConnectorCreateInput.parse({
...parsedInput.connectorInput,
type:
parsedInput.connectorInput.type === "formbricks"
? "formbricks_survey"
: parsedInput.connectorInput.type,
});
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -190,7 +165,7 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
// Verify FRD belongs to same org
const frd = await prisma.feedbackRecordDirectory.findUnique({
where: { id: connectorInput.feedbackRecordDirectoryId },
where: { id: parsedInput.connectorInput.feedbackRecordDirectoryId },
select: { organizationId: true },
});
if (frd?.organizationId !== organizationId) {
@@ -218,7 +193,7 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
return createConnectorWithMappings(
parsedInput.workspaceId,
{ ...connectorInput, createdBy: ctx.user.id },
{ ...parsedInput.connectorInput, createdBy: ctx.user.id },
mappingsInput
);
});
@@ -492,7 +467,6 @@ export const importCsvDataAction = authenticatedActionClient
const ZListFeedbackRecordsAction = z.object({
workspaceId: ZId,
frdId: ZId,
limit: z.number().min(1).max(1000).optional(),
cursor: z.string().optional(),
sourceType: z.string().optional(),
@@ -530,28 +504,38 @@ export const listFeedbackRecordsAction = authenticatedActionClient
],
});
// Verify FRD belongs to workspace's accessible FRDs
// tenant_id = FRD id. Fan out across all FRDs assigned to this workspace, merge + sort desc.
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(parsedInput.workspaceId);
if (!frds.some((f) => f.id === parsedInput.frdId)) {
throw new Error("Feedback record directory not accessible");
if (frds.length === 0) {
return { data: [], limit: parsedInput.limit ?? 50 };
}
const params: FeedbackRecordListParams = {
tenant_id: parsedInput.frdId,
limit: parsedInput.limit ?? 50,
const perFrdLimit = parsedInput.limit ?? 50;
const baseParams = {
limit: perFrdLimit,
...(parsedInput.sourceType ? { source_type: parsedInput.sourceType } : {}),
...(parsedInput.fieldType ? { field_type: parsedInput.fieldType } : {}),
...(parsedInput.since ? { since: parsedInput.since } : {}),
...(parsedInput.until ? { until: parsedInput.until } : {}),
};
if (parsedInput.cursor) params.cursor = parsedInput.cursor;
if (parsedInput.sourceType) params.source_type = parsedInput.sourceType;
if (parsedInput.fieldType) params.field_type = parsedInput.fieldType;
if (parsedInput.since) params.since = parsedInput.since;
if (parsedInput.until) params.until = parsedInput.until;
const result = await listFeedbackRecords(params);
if (result.error || !result.data) {
logger.warn({ error: result.error }, "Failed to list feedback records");
throw new Error(result.error?.message ?? "Failed to load feedback records");
const results = await Promise.all(
frds.map((frd) =>
listFeedbackRecords({ ...baseParams, tenant_id: frd.id } as FeedbackRecordListParams)
)
);
const errored = results.find((r) => r.error);
if (errored?.error) {
logger.warn({ error: errored.error }, "Failed to list feedback records");
throw new Error(errored.error.message);
}
return result.data;
const merged = results
.flatMap((r) => r.data?.data ?? [])
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.slice(0, perFrdLimit);
return { data: merged, limit: perFrdLimit };
}
);
-1
View File
@@ -185,7 +185,6 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ro-RO",
"ru-RU",
"sv-SE",
"tr-TR",
"zh-Hans-CN",
"zh-Hant-TW",
];
+30 -48
View File
@@ -14,8 +14,6 @@ import {
verifySecret,
} from "./crypto";
const SECRET_HASH_TEST_TIMEOUT_MS = 45_000;
// Unmock crypto for these tests since we want to test the actual crypto functions
vi.unmock("crypto");
@@ -28,61 +26,45 @@ vi.mock("@formbricks/logger", () => ({
describe("Crypto Utils", () => {
describe("hashSecret and verifySecret", () => {
test(
"should hash and verify secrets correctly",
async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret);
test("should hash and verify secrets correctly", async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret);
expect(hash).toMatch(/^\$2[aby]\$\d+\$[./A-Za-z0-9]{53}$/);
expect(hash).toMatch(/^\$2[aby]\$\d+\$[./A-Za-z0-9]{53}$/);
const isValid = await verifySecret(secret, hash);
expect(isValid).toBe(true);
},
SECRET_HASH_TEST_TIMEOUT_MS
);
const isValid = await verifySecret(secret, hash);
expect(isValid).toBe(true);
});
test(
"should reject wrong secrets",
async () => {
const secret = "test-secret-123";
const wrongSecret = "wrong-secret";
const hash = await hashSecret(secret);
test("should reject wrong secrets", async () => {
const secret = "test-secret-123";
const wrongSecret = "wrong-secret";
const hash = await hashSecret(secret);
const isValid = await verifySecret(wrongSecret, hash);
expect(isValid).toBe(false);
},
SECRET_HASH_TEST_TIMEOUT_MS
);
const isValid = await verifySecret(wrongSecret, hash);
expect(isValid).toBe(false);
});
test(
"should generate different hashes for the same secret (due to salt)",
async () => {
const secret = "test-secret-123";
const hash1 = await hashSecret(secret);
const hash2 = await hashSecret(secret);
test("should generate different hashes for the same secret (due to salt)", async () => {
const secret = "test-secret-123";
const hash1 = await hashSecret(secret);
const hash2 = await hashSecret(secret);
expect(hash1).not.toBe(hash2);
expect(hash1).not.toBe(hash2);
// But both should verify correctly
expect(await verifySecret(secret, hash1)).toBe(true);
expect(await verifySecret(secret, hash2)).toBe(true);
},
SECRET_HASH_TEST_TIMEOUT_MS
);
// But both should verify correctly
expect(await verifySecret(secret, hash1)).toBe(true);
expect(await verifySecret(secret, hash2)).toBe(true);
}, 15000);
test(
"should use custom cost factor",
async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret, 10);
test("should use custom cost factor", async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret, 10);
// Verify the cost factor is in the hash
expect(hash).toMatch(/^\$2[aby]\$10\$/);
expect(await verifySecret(secret, hash)).toBe(true);
},
SECRET_HASH_TEST_TIMEOUT_MS
);
// Verify the cost factor is in the hash
expect(hash).toMatch(/^\$2[aby]\$10\$/);
expect(await verifySecret(secret, hash)).toBe(true);
});
test("should return false for invalid hash format", async () => {
const secret = "test-secret-123";
+1 -63
View File
@@ -8,7 +8,6 @@ const setTestEnv = (overrides: Record<string, string | undefined> = {}) => {
NODE_ENV: "test",
DATABASE_URL: "https://example.com/db",
ENCRYPTION_KEY: "12345678901234567890123456789012",
HUB_API_URL: "https://hub.formbricks.local",
...overrides,
};
};
@@ -22,22 +21,13 @@ describe("env", () => {
process.env = ORIGINAL_ENV;
});
test("allows ambient DEBUG values from external tooling", async () => {
setTestEnv({
DEBUG: "pnpm:*",
});
const { env } = await import("./env");
expect(env.DEBUG).toBe("pnpm:*");
});
test("uses the default password reset token lifetime when env var is not set", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: undefined,
});
const { env } = await import("./env");
expect(env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES).toBe(30);
});
@@ -77,58 +67,6 @@ describe("env", () => {
expect(env.DEBUG_SHOW_RESET_LINK).toBe("1");
});
test("uses the default survey scheduling configuration when env vars are not set", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: undefined,
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: undefined,
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: undefined,
});
const { env } = await import("./env");
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE).toBe("Europe/Berlin");
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR).toBe(0);
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE).toBe(0);
});
test("uses the configured survey scheduling configuration", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: "18",
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: "45",
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: "America/New_York",
});
const { env } = await import("./env");
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE).toBe("America/New_York");
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR).toBe(18);
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE).toBe(45);
});
test("fails to load when the survey scheduling timezone is invalid", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: "Mars/OlympusMons",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("fails to load when the survey scheduling hour is out of range", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: "24",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("fails to load when the survey scheduling minute is out of range", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: "60",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("fails to load when DEBUG_SHOW_RESET_LINK is invalid", async () => {
setTestEnv({
DEBUG_SHOW_RESET_LINK: "true",
+1 -35
View File
@@ -107,22 +107,6 @@ const validateActiveAIProviderConfiguration = (values: TAIConfigurationEnv, ctx:
providerValidators[values.AI_PROVIDER](values, ctx);
};
const isValidIanaTimeZone = (value: string): boolean => {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value });
return true;
} catch {
return false;
}
};
const ZSurveySchedulingTimeZone = z.string().trim().min(1).refine(isValidIanaTimeZone, {
message: "NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE must be a valid IANA time zone",
});
const ZSurveySchedulingLocalHour = z.coerce.number().int().min(0).max(23);
const ZSurveySchedulingLocalMinute = z.coerce.number().int().min(0).max(59);
const parsedEnv = createEnv({
/*
* Serverside Environment variables, not available on the client.
@@ -140,16 +124,10 @@ const parsedEnv = createEnv({
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
// DEBUG is a common ambient env var in CI/tooling, so we accept arbitrary strings here
// and only treat "1" as enabling Formbricks-specific debug behavior downstream.
DEBUG: z.string().optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
BULLMQ_WORKER_CONCURRENCY: z.coerce.number().int().min(1).optional(),
BULLMQ_WORKER_COUNT: z.coerce.number().int().min(1).optional(),
BULLMQ_EXTERNAL_WORKER_ENABLED: z.enum(["1", "0"]).optional(),
BULLMQ_WORKER_ENABLED: z.enum(["1", "0"]).optional(),
E2E_TESTING: z.enum(["1", "0"]).optional(),
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
@@ -273,11 +251,6 @@ const parsedEnv = createEnv({
.optional(),
SENTRY_ENVIRONMENT: z.string().optional(),
},
client: {
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: ZSurveySchedulingTimeZone.optional().default("Europe/Berlin"),
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: ZSurveySchedulingLocalHour.optional().default(0),
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: ZSurveySchedulingLocalMinute.optional().default(0),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
@@ -301,10 +274,6 @@ const parsedEnv = createEnv({
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
BULLMQ_EXTERNAL_WORKER_ENABLED: process.env.BULLMQ_EXTERNAL_WORKER_ENABLED,
BULLMQ_WORKER_CONCURRENCY: process.env.BULLMQ_WORKER_CONCURRENCY,
BULLMQ_WORKER_COUNT: process.env.BULLMQ_WORKER_COUNT,
BULLMQ_WORKER_ENABLED: process.env.BULLMQ_WORKER_ENABLED,
E2E_TESTING: process.env.E2E_TESTING,
EMAIL_AUTH_DISABLED: process.env.EMAIL_AUTH_DISABLED,
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
@@ -346,9 +315,6 @@ const parsedEnv = createEnv({
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: process.env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR,
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: process.env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE,
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: process.env.NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE,
SENTRY_DSN: process.env.SENTRY_DSN,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
-7
View File
@@ -213,13 +213,6 @@ export const appLanguages = [
native: "Svenska",
},
},
{
code: "tr-TR",
label: {
"en-US": "Turkish",
native: "Türkçe",
},
},
{
code: "zh-Hans-CN",
label: {
+1 -1
View File
@@ -122,7 +122,7 @@ describe("Integration Service", () => {
},
];
test("should get all integrations for a workspace", async () => {
test("should get all integrations for an environment", async () => {
vi.mocked(prisma.integration.findMany).mockResolvedValue(mockIntegrations);
const result = await getIntegrations(mockWorkspaceId);
-180
View File
@@ -1,180 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const TEST_TIMEOUT_MS = 15_000;
describe("jobs runtime config", () => {
beforeEach(() => {
vi.resetModules();
});
test(
"defaults to one worker with concurrency one outside tests",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: undefined,
BULLMQ_WORKER_COUNT: undefined,
BULLMQ_WORKER_ENABLED: undefined,
NODE_ENV: "development",
REDIS_URL: "redis://localhost:6379",
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: true,
runtimeOptions: {
concurrency: 1,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
expect(getJobsQueueingConfig()).toEqual({
enabled: true,
redisUrl: "redis://localhost:6379",
});
},
TEST_TIMEOUT_MS
);
test(
"disables the worker by default in tests",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: undefined,
BULLMQ_WORKER_COUNT: undefined,
BULLMQ_WORKER_ENABLED: undefined,
NODE_ENV: "test",
REDIS_URL: undefined,
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: false,
runtimeOptions: null,
});
expect(getJobsQueueingConfig()).toEqual({
enabled: false,
redisUrl: null,
});
},
TEST_TIMEOUT_MS
);
test(
"uses explicit worker tuning overrides",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: 6,
BULLMQ_WORKER_COUNT: 3,
BULLMQ_WORKER_ENABLED: "1",
NODE_ENV: "production",
REDIS_URL: "redis://cache.internal:6379",
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: true,
runtimeOptions: {
concurrency: 6,
redisUrl: "redis://cache.internal:6379",
workerCount: 3,
},
});
expect(getJobsQueueingConfig()).toEqual({
enabled: true,
redisUrl: "redis://cache.internal:6379",
});
},
TEST_TIMEOUT_MS
);
test(
"disables queueing when no BullMQ consumer is configured",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: 6,
BULLMQ_WORKER_COUNT: 3,
BULLMQ_WORKER_ENABLED: "0",
NODE_ENV: "production",
REDIS_URL: "redis://cache.internal:6379",
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: false,
runtimeOptions: null,
});
expect(getJobsQueueingConfig()).toEqual({
enabled: false,
redisUrl: null,
});
},
TEST_TIMEOUT_MS
);
test(
"keeps queueing enabled when an external BullMQ worker is configured",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: "1",
BULLMQ_WORKER_CONCURRENCY: 6,
BULLMQ_WORKER_COUNT: 3,
BULLMQ_WORKER_ENABLED: "0",
NODE_ENV: "production",
REDIS_URL: "redis://cache.internal:6379",
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: false,
runtimeOptions: null,
});
expect(getJobsQueueingConfig()).toEqual({
enabled: true,
redisUrl: "redis://cache.internal:6379",
});
},
TEST_TIMEOUT_MS
);
test(
"throws when the worker is enabled without a redis url",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: 2,
BULLMQ_WORKER_COUNT: 1,
BULLMQ_WORKER_ENABLED: "1",
NODE_ENV: "production",
REDIS_URL: undefined,
},
}));
const { getJobsWorkerBootstrapConfig } = await import("./config");
expect(() => getJobsWorkerBootstrapConfig()).toThrow(
"REDIS_URL is required to start the BullMQ worker"
);
},
TEST_TIMEOUT_MS
);
});
-68
View File
@@ -1,68 +0,0 @@
import "server-only";
import type { JobsRuntimeOptions } from "@formbricks/jobs";
import { env } from "@/lib/env";
const DEFAULT_BULLMQ_WORKER_CONCURRENCY = 1;
const DEFAULT_BULLMQ_WORKER_COUNT = 1;
export interface JobsWorkerBootstrapConfig {
enabled: boolean;
runtimeOptions: JobsRuntimeOptions | null;
}
export interface JobsQueueingConfig {
enabled: boolean;
redisUrl: string | null;
}
export const BULLMQ_WORKER_CONCURRENCY = env.BULLMQ_WORKER_CONCURRENCY ?? DEFAULT_BULLMQ_WORKER_CONCURRENCY;
export const BULLMQ_WORKER_COUNT = env.BULLMQ_WORKER_COUNT ?? DEFAULT_BULLMQ_WORKER_COUNT;
const getBullMqWorkerEnabled = (): boolean => {
if (env.BULLMQ_WORKER_ENABLED !== undefined) {
return env.BULLMQ_WORKER_ENABLED === "1";
}
return env.NODE_ENV !== "test";
};
export const BULLMQ_WORKER_ENABLED = getBullMqWorkerEnabled();
export const BULLMQ_EXTERNAL_WORKER_ENABLED = env.BULLMQ_EXTERNAL_WORKER_ENABLED === "1";
const hasBullMqConsumer = (): boolean => BULLMQ_WORKER_ENABLED || BULLMQ_EXTERNAL_WORKER_ENABLED;
export const getJobsQueueingConfig = (): JobsQueueingConfig => {
if (!env.REDIS_URL || !hasBullMqConsumer()) {
return {
enabled: false,
redisUrl: null,
};
}
return {
enabled: true,
redisUrl: env.REDIS_URL,
};
};
export const getJobsWorkerBootstrapConfig = (): JobsWorkerBootstrapConfig => {
if (!BULLMQ_WORKER_ENABLED) {
return {
enabled: false,
runtimeOptions: null,
};
}
if (!env.REDIS_URL) {
throw new Error("REDIS_URL is required to start the BullMQ worker");
}
return {
enabled: true,
runtimeOptions: {
concurrency: BULLMQ_WORKER_CONCURRENCY,
redisUrl: env.REDIS_URL,
workerCount: BULLMQ_WORKER_COUNT,
},
};
};
+14 -35
View File
@@ -93,14 +93,6 @@ export const getResponseContact = (
};
};
const mapResponsePrismaToResponse = (
responsePrisma: Prisma.ResponseGetPayload<{ select: typeof responseSelection }>
): TResponse => ({
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
});
export const getResponsesByContactId = reactCache(
async (contactId: string, page?: number): Promise<TResponseWithQuotas[]> => {
validateInputs([contactId, ZId], [page, ZOptionalNumber]);
@@ -180,7 +172,13 @@ export const getResponseBySingleUseId = reactCache(
return null;
}
return mapResponsePrismaToResponse(responsePrisma);
const response: TResponse = {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -206,7 +204,13 @@ export const getResponse = reactCache(async (responseId: string): Promise<TRespo
return null;
}
return mapResponsePrismaToResponse(responsePrisma);
const response: TResponse = {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -216,31 +220,6 @@ export const getResponse = reactCache(async (responseId: string): Promise<TRespo
}
});
export const getResponseSnapshotForPipeline = async (responseId: string): Promise<TResponse | null> => {
validateInputs([responseId, ZId]);
try {
const responsePrisma = await prisma.response.findUnique({
where: {
id: responseId,
},
select: responseSelection,
});
if (!responsePrisma) {
return null;
}
return mapResponsePrismaToResponse(responsePrisma);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getResponseFilteringValues = reactCache(async (surveyId: string) => {
validateInputs([surveyId, ZId]);
@@ -186,8 +186,6 @@ const baseSurveyProperties = {
autoClose: 10,
delay: 0,
autoComplete: 7,
publishOn: null,
closeOn: null,
redirectUrl: "https://github.com/formbricks/formbricks",
recontactDays: 3,
displayLimit: 3,
@@ -1,379 +0,0 @@
import { prisma } from "@/lib/__mocks__/database";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ValidationError } from "@formbricks/types/errors";
import { getActionClasses } from "@/lib/actionClass/service";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import {
createSurveyInput,
mockActionClass,
mockSurveyOutput,
updateSurveyInput,
} from "./__mock__/survey.mock";
import { createSurvey, updateSurveyInternal } from "./service";
const { mockQueueAuditEventWithoutRequest } = vi.hoisted(() => ({
mockQueueAuditEventWithoutRequest: vi.fn(),
}));
vi.mock("@/lib/actionClass/service", () => ({
getActionClasses: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByWorkspaceId: vi.fn(),
subscribeOrganizationMembersToSurveyResponses: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventWithoutRequest: mockQueueAuditEventWithoutRequest,
}));
const createSchedulingCandidate = ({
id = updateSurveyInput.id,
closeOn,
publishOn,
status,
workspaceId = updateSurveyInput.workspaceId,
}: {
id?: string;
closeOn: Date | null;
publishOn: Date | null;
status: "draft" | "paused" | "inProgress";
workspaceId?: string;
}) => ({
id,
closeOn,
publishOn,
status,
workspace: {
organizationId: "org123",
},
workspaceId,
});
describe("survey service scheduling", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-17T12:30:00.000Z"));
vi.mocked(getActionClasses).mockResolvedValue([mockActionClass] as never);
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValue({ id: "org123" } as never);
mockQueueAuditEventWithoutRequest.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
test("manual publish clears publishOn", async () => {
const scheduledPublishSelection = new Date(Date.UTC(2026, 3, 20, 12, 0, 0));
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: scheduledPublishSelection,
status: "draft",
} as never);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: null,
status: "inProgress",
} as never);
await updateSurveyInternal(
{
...updateSurveyInput,
publishOn: scheduledPublishSelection,
status: "inProgress",
},
true
);
expect(prisma.survey.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
publishOn: null,
status: "inProgress",
}),
})
);
});
test("manual paused status clears closeOn", async () => {
const scheduledCloseSelection = new Date(Date.UTC(2026, 3, 20, 12, 0, 0));
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: scheduledCloseSelection,
status: "inProgress",
} as never);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: null,
status: "paused",
} as never);
await updateSurveyInternal(
{
...updateSurveyInput,
closeOn: scheduledCloseSelection,
status: "paused",
},
true
);
expect(prisma.survey.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
closeOn: null,
status: "paused",
}),
})
);
});
test("scheduling from draft keeps closeOn when publishOn is set", async () => {
const scheduledPublishSelection = new Date(Date.UTC(2026, 3, 20, 12, 0, 0));
const scheduledCloseSelection = new Date(Date.UTC(2026, 3, 21, 12, 0, 0));
const normalizedPublishOn = new Date("2026-04-19T22:00:00.000Z");
const normalizedCloseOn = new Date("2026-04-20T22:00:00.000Z");
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: null,
publishOn: null,
status: "draft",
} as never);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: normalizedCloseOn,
publishOn: normalizedPublishOn,
status: "paused",
} as never);
prisma.survey.findMany.mockResolvedValueOnce([] as never).mockResolvedValueOnce([] as never);
await updateSurveyInternal(
{
...updateSurveyInput,
closeOn: scheduledCloseSelection,
publishOn: scheduledPublishSelection,
status: "paused",
},
true
);
expect(prisma.survey.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
closeOn: normalizedCloseOn,
publishOn: normalizedPublishOn,
status: "paused",
}),
})
);
});
test("manual completion clears both scheduling dates", async () => {
const scheduledSelection = new Date(Date.UTC(2026, 3, 20, 12, 0, 0));
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: scheduledSelection,
publishOn: scheduledSelection,
status: "inProgress",
} as never);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: null,
publishOn: null,
status: "completed",
} as never);
await updateSurveyInternal(
{
...updateSurveyInput,
closeOn: scheduledSelection,
publishOn: scheduledSelection,
status: "completed",
},
true
);
expect(prisma.survey.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
closeOn: null,
publishOn: null,
status: "completed",
}),
})
);
});
test("saving a due publish schedule catches up immediately for paused surveys", async () => {
const dueSelection = new Date(Date.UTC(2026, 3, 17, 12, 0, 0));
const normalizedDuePublishOn = new Date("2026-04-16T22:00:00.000Z");
prisma.survey.findUnique
.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: null,
status: "paused",
} as never)
.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: null,
status: "inProgress",
} as never);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: normalizedDuePublishOn,
status: "paused",
} as never);
prisma.survey.findMany
.mockResolvedValueOnce([
createSchedulingCandidate({
closeOn: null,
publishOn: normalizedDuePublishOn,
status: "paused",
}),
] as never)
.mockResolvedValueOnce([] as never);
prisma.survey.updateMany.mockResolvedValueOnce({ count: 1 } as never);
const updatedSurvey = await updateSurveyInternal(
{
...updateSurveyInput,
publishOn: dueSelection,
status: "paused",
},
true
);
expect(updatedSurvey.status).toBe("inProgress");
expect(prisma.survey.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
publishOn: normalizedDuePublishOn,
}),
})
);
expect(mockQueueAuditEventWithoutRequest).toHaveBeenCalledTimes(1);
});
test("saving a due publish schedule in draft does not auto-publish", async () => {
const dueSelection = new Date(Date.UTC(2026, 3, 17, 12, 0, 0));
const normalizedDuePublishOn = new Date("2026-04-16T22:00:00.000Z");
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: null,
status: "draft",
} as never);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: normalizedDuePublishOn,
status: "draft",
} as never);
prisma.survey.findMany.mockResolvedValueOnce([] as never).mockResolvedValueOnce([] as never);
const updatedSurvey = await updateSurveyInternal(
{
...updateSurveyInput,
publishOn: dueSelection,
status: "draft",
},
true
);
expect(updatedSurvey.status).toBe("draft");
expect(prisma.survey.updateMany).not.toHaveBeenCalled();
expect(mockQueueAuditEventWithoutRequest).toHaveBeenCalledTimes(0);
});
test("creating a paused survey with a due publish schedule catches up immediately", async () => {
const dueSelection = new Date(Date.UTC(2026, 3, 17, 12, 0, 0));
const normalizedDuePublishOn = new Date("2026-04-16T22:00:00.000Z");
prisma.survey.create.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: normalizedDuePublishOn,
status: "paused",
type: "link",
} as never);
prisma.survey.findMany
.mockResolvedValueOnce([
createSchedulingCandidate({
closeOn: null,
publishOn: normalizedDuePublishOn,
status: "paused",
}),
] as never)
.mockResolvedValueOnce([] as never);
prisma.survey.updateMany.mockResolvedValueOnce({ count: 1 } as never);
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: null,
status: "inProgress",
type: "link",
} as never);
const createdSurvey = await createSurvey(updateSurveyInput.workspaceId, {
...createSurveyInput,
name: "Scheduled survey",
publishOn: dueSelection,
status: "paused",
type: "link",
});
expect(createdSurvey.status).toBe("inProgress");
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
publishOn: normalizedDuePublishOn,
}),
})
);
expect(mockQueueAuditEventWithoutRequest).toHaveBeenCalledTimes(1);
});
test("same-day publish and close is rejected", async () => {
const sameDaySelection = new Date(Date.UTC(2026, 3, 17, 12, 0, 0));
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: null,
publishOn: null,
status: "paused",
} as never);
await expect(
updateSurveyInternal(
{
...updateSurveyInput,
closeOn: sameDaySelection,
publishOn: sameDaySelection,
status: "paused",
},
true
)
).rejects.toThrow(ValidationError);
expect(prisma.survey.update).not.toHaveBeenCalled();
expect(mockQueueAuditEventWithoutRequest).not.toHaveBeenCalled();
});
test("creating a survey with the same publish and close date is rejected", async () => {
const sameDaySelection = new Date(Date.UTC(2026, 3, 17, 12, 0, 0));
await expect(
createSurvey(updateSurveyInput.workspaceId, {
...createSurveyInput,
closeOn: sameDaySelection,
name: "Scheduled survey",
publishOn: sameDaySelection,
status: "paused",
type: "link",
})
).rejects.toThrow(ValidationError);
expect(prisma.survey.create).not.toHaveBeenCalled();
expect(mockQueueAuditEventWithoutRequest).not.toHaveBeenCalled();
});
});
+21 -37
View File
@@ -5,12 +5,7 @@ import { testInputValidation } from "vitestSetup";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TActionClass } from "@formbricks/types/action-classes";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyCreateInput, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getActionClasses } from "@/lib/actionClass/service";
@@ -19,7 +14,6 @@ import {
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { evaluateLogic } from "@/lib/surveyLogic/utils";
import { handleTriggerUpdates } from "@/modules/survey/lib/trigger-updates";
import {
mockActionClass,
mockId,
@@ -36,13 +30,12 @@ import {
getSurveys,
getSurveysByActionClassId,
getSurveysBySegmentId,
handleTriggerUpdates,
loadNewSegmentInSurvey,
updateSurvey,
updateSurveyInternal,
} from "./service";
const SURVEY_SERVICE_TEST_TIMEOUT_MS = 30_000;
// Mock organization service
vi.mock("@/lib/organization/service", () => ({
getOrganizationByWorkspaceId: vi.fn().mockResolvedValue({
@@ -322,13 +315,7 @@ describe("Tests for updateSurvey", () => {
});
describe("Sad Path", () => {
test(
"throws a ValidationError if the inputs are invalid",
async () => {
await expect(updateSurvey("123#" as unknown as TSurvey)).rejects.toThrow(ValidationError);
},
SURVEY_SERVICE_TEST_TIMEOUT_MS
);
testInputValidation(updateSurvey, "123#");
test("Throws ResourceNotFoundError if the survey does not exist", async () => {
prisma.survey.findUnique.mockRejectedValueOnce(
@@ -359,12 +346,12 @@ describe("Tests for updateSurvey", () => {
describe("Tests for getSurveyCount service", () => {
describe("Happy Path", () => {
test("Counts the total number of surveys for a given workspace ID", async () => {
test("Counts the total number of surveys for a given environment ID", async () => {
const count = await getSurveyCount(mockId);
expect(count).toEqual(1);
});
test("Returns zero count when there are no surveys for a given workspace ID", async () => {
test("Returns zero count when there are no surveys for a given environment ID", async () => {
prisma.survey.count.mockResolvedValue(0);
const count = await getSurveyCount(mockId);
expect(count).toEqual(0);
@@ -644,6 +631,7 @@ describe("Tests for createSurvey", () => {
beforeEach(() => {
vi.mocked(getActionClasses).mockResolvedValue(mockActionClasses as TActionClass[]);
// environment model removed - no mock needed
});
describe("Happy Path", () => {
@@ -1019,25 +1007,21 @@ describe("updateSurveyDraftAction", () => {
});
describe("Sad Path", () => {
test(
"should reject publishing survey with incomplete translations",
async () => {
// Create a draft with missing translations
const incompleteSurvey = {
...updateSurveyInput,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
// Missing headline
},
],
} as unknown as TSurvey;
test("should reject publishing survey with incomplete translations", async () => {
// Create a draft with missing translations
const incompleteSurvey = {
...updateSurveyInput,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
// Missing headline
},
],
} as unknown as TSurvey;
// Expect validation error (skipValidation = false)
await expect(updateSurveyInternal(incompleteSurvey, false)).rejects.toThrow();
},
SURVEY_SERVICE_TEST_TIMEOUT_MS
);
// Expect validation error (skipValidation = false)
await expect(updateSurveyInternal(incompleteSurvey, false)).rejects.toThrow();
});
});
});
+118 -134
View File
@@ -1,5 +1,5 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { ActionClass, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -11,12 +11,7 @@ import {
getOrganizationByWorkspaceId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { handleTriggerUpdates } from "@/modules/survey/lib/trigger-updates";
import {
isSurveySchedulingDue,
normalizeSurveyScheduling,
reconcileDueSurveySchedules,
} from "@/modules/survey/scheduling/lib/survey-scheduling";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -50,8 +45,6 @@ export const selectSurvey = {
delay: true,
displayPercentage: true,
autoComplete: true,
publishOn: true,
closeOn: true,
isVerifyEmailEnabled: true,
isSingleResponsePerEmailEnabled: true,
isBackButtonHidden: true,
@@ -114,45 +107,74 @@ export const selectSurvey = {
slug: true,
} satisfies Prisma.SurveySelect;
const reconcilePersistedSurveySchedulingIfDue = async ({
logSource,
survey,
workspaceId,
}: {
logSource: "survey-create" | "survey-update";
survey: TSurvey;
workspaceId: string;
}): Promise<TSurvey> => {
const now = new Date();
if (!isSurveySchedulingDue(survey, now)) {
return survey;
const getTriggerIds = (triggers: TSurvey["triggers"]): string[] | null => {
if (!triggers) return null;
if (!Array.isArray(triggers)) {
throw new InvalidInputError("Invalid trigger id");
}
const reconciliationResult = await reconcileDueSurveySchedules({
logContext: {
source: logSource,
surveyId: survey.id,
workspaceId,
},
now,
surveyId: survey.id,
return triggers.map((trigger) => {
const actionClassId = trigger?.actionClass?.id;
if (typeof actionClassId !== "string") {
throw new InvalidInputError("Invalid trigger id");
}
return actionClassId;
});
};
export const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => {
const triggerIds = getTriggerIds(triggers);
if (!triggerIds) return;
// check if all the triggers are valid
triggerIds.forEach((triggerId) => {
if (!actionClasses.find((actionClass) => actionClass.id === triggerId)) {
throw new InvalidInputError("Invalid trigger id");
}
});
if (!reconciliationResult.surveyUpdated) {
return survey;
if (new Set(triggerIds).size !== triggerIds.length) {
throw new InvalidInputError("Duplicate trigger id");
}
};
export const handleTriggerUpdates = (
updatedTriggers: TSurvey["triggers"],
currentTriggers: TSurvey["triggers"],
actionClasses: ActionClass[]
) => {
const updatedTriggerIds = getTriggerIds(updatedTriggers);
if (!updatedTriggerIds) return {};
checkTriggersValidity(updatedTriggers, actionClasses);
const currentTriggerIds = getTriggerIds(currentTriggers) ?? [];
// added triggers are triggers that are not in the current triggers and are there in the new triggers
const addedTriggerIds = updatedTriggerIds.filter((triggerId) => !currentTriggerIds.includes(triggerId));
// deleted triggers are triggers that are not in the new triggers and are there in the current triggers
const deletedTriggerIds = currentTriggerIds.filter((triggerId) => !updatedTriggerIds.includes(triggerId));
// Construct the triggers update object
const triggersUpdate: TriggerUpdate = {};
if (addedTriggerIds.length > 0) {
triggersUpdate.create = addedTriggerIds.map((triggerId) => ({
actionClassId: triggerId,
}));
}
const reconciledSurvey = await prisma.survey.findUnique({
where: { id: survey.id },
select: selectSurvey,
});
if (!reconciledSurvey) {
throw new ResourceNotFoundError("Survey", survey.id);
if (deletedTriggerIds.length > 0) {
// disconnect the public triggers from the survey
triggersUpdate.deleteMany = {
actionClassId: {
in: deletedTriggerIds,
},
};
}
return transformPrismaSurvey<TSurvey>(reconciledSurvey);
return triggersUpdate;
};
export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey | null> => {
@@ -515,16 +537,12 @@ export const updateSurveyInternal = async (
data.blocks = stripIsDraftFromBlocks(updatedSurvey.blocks);
}
const normalizedScheduling = normalizeSurveyScheduling({
currentStatus: currentSurvey.status,
closeOn: surveyData.closeOn,
publishOn: surveyData.publishOn,
status: updatedSurvey.status,
});
const organization = await getOrganizationByWorkspaceId(updatedSurvey.workspaceId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
surveyData.updatedAt = new Date();
surveyData.publishOn = normalizedScheduling.publishOn;
surveyData.closeOn = normalizedScheduling.closeOn;
data = {
...surveyData,
@@ -533,17 +551,28 @@ export const updateSurveyInternal = async (
};
delete data.createdBy;
const persistedSurvey = await prisma.survey.update({
const prismaSurvey = await prisma.survey.update({
where: { id: surveyId },
data,
select: selectSurvey,
});
return await reconcilePersistedSurveySchedulingIfDue({
logSource: "survey-update",
survey: transformPrismaSurvey<TSurvey>(persistedSurvey),
workspaceId: updatedSurvey.workspaceId,
});
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
} catch (error) {
logger.error(error, "Error updating survey");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -563,63 +592,6 @@ export const updateSurveyDraft = async (updatedSurvey: TSurvey): Promise<TSurvey
return updateSurveyInternal(updatedSurvey, true);
};
const attachSurveyCreatorToCreateData = (
data: Omit<Prisma.SurveyCreateInput, "workspace">,
createdBy?: string | null
): Omit<Prisma.SurveyCreateInput, "workspace"> => {
if (!createdBy) {
return data;
}
return {
...data,
creator: {
connect: {
id: createdBy,
},
},
};
};
const attachSurveyFollowUpsToCreateData = (
data: Omit<Prisma.SurveyCreateInput, "workspace">,
followUps?: TSurveyCreateInput["followUps"]
): Omit<Prisma.SurveyCreateInput, "workspace"> => {
const { followUps: _, ...dataWithoutFollowUps } = data;
if (!followUps?.length) {
return dataWithoutFollowUps;
}
return {
...dataWithoutFollowUps,
followUps: {
create: followUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
},
};
};
const validateSurveyCreateDataMedia = (
data: Omit<Prisma.SurveyCreateInput, "workspace">
): Omit<Prisma.SurveyCreateInput, "workspace"> => {
if (data.questions) {
checkForInvalidImagesInQuestions(data.questions);
}
if (data.blocks?.length) {
return {
...data,
blocks: validateMediaAndPrepareBlocks(data.blocks),
};
}
return data;
};
export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreateInput): Promise<TSurvey> => {
const [parsedWorkspaceId, parsedSurveyBody] = validateInputs(
[workspaceId, ZId],
@@ -628,37 +600,55 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
try {
const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody;
const normalizedCloseOn = restSurveyBody.closeOn instanceof Date ? restSurveyBody.closeOn : null;
const normalizedPublishOn = restSurveyBody.publishOn instanceof Date ? restSurveyBody.publishOn : null;
const actionClasses = await getActionClasses(parsedWorkspaceId);
const baseData: Omit<Prisma.SurveyCreateInput, "workspace"> = {
let data: Omit<Prisma.SurveyCreateInput, "workspace"> = {
...restSurveyBody,
...normalizeSurveyScheduling({
closeOn: normalizedCloseOn,
publishOn: normalizedPublishOn,
status: restSurveyBody.status ?? "draft",
}),
// @ts-expect-error - languages would be undefined in case of empty array
languages: languages?.length ? languages : undefined,
triggers: restSurveyBody.triggers
? handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
? // @ts-expect-error - triggers' createdAt and updatedAt are actually dates
handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
: undefined,
attributeFilters: undefined,
};
const data = validateSurveyCreateDataMedia(
attachSurveyFollowUpsToCreateData(
attachSurveyCreatorToCreateData(baseData, createdBy),
restSurveyBody.followUps
)
);
if (createdBy) {
data.creator = {
connect: {
id: createdBy,
},
};
}
const organization = await getOrganizationByWorkspaceId(parsedWorkspaceId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
// Survey follow-ups
if (restSurveyBody.followUps?.length) {
data.followUps = {
create: restSurveyBody.followUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
};
} else {
delete data.followUps;
}
if (data.questions) {
checkForInvalidImagesInQuestions(data.questions);
}
// Validate and prepare blocks for persistence
if (data.blocks && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
}
const survey = await prisma.survey.create({
data: {
...data,
@@ -712,17 +702,11 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
}),
};
const reconciledSurvey = await reconcilePersistedSurveySchedulingIfDue({
logSource: "survey-create",
survey: transformedSurvey,
workspaceId: parsedWorkspaceId,
});
if (createdBy) {
await subscribeOrganizationMembersToSurveyResponses(reconciledSurvey.id, createdBy, organization.id);
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
}
return reconciledSurvey;
return transformedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error creating survey");
+1 -2
View File
@@ -1,5 +1,5 @@
import { type Locale, formatDistance } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, tr, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "./utils/datetime";
@@ -17,7 +17,6 @@ const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = {
"ro-RO": ro,
"ru-RU": ru,
"sv-SE": sv,
"tr-TR": tr,
"zh-Hans-CN": zhCN,
"zh-Hant-TW": zhTW,
};
+1 -1
View File
@@ -54,7 +54,7 @@ export const findRecallInfoById = (text: string, id: string): string | null => {
return match ? match[0] : null;
};
export const getRecallItemLabel = <T extends TSurvey>(
const getRecallItemLabel = <T extends TSurvey>(
recallItemId: string,
survey: T,
languageCode: string
+1 -20
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { isSafeIdentifier, toSafeIdentifier } from "./safe-identifier";
import { isSafeIdentifier } from "./safe-identifier";
describe("safe-identifier", () => {
describe("isSafeIdentifier", () => {
@@ -32,23 +32,4 @@ describe("safe-identifier", () => {
expect(isSafeIdentifier("")).toBe(false);
});
});
describe("toSafeIdentifier", () => {
test("normalizes free-form labels into safe identifiers", () => {
expect(toSafeIdentifier("Date of Birth")).toBe("date_of_birth");
expect(toSafeIdentifier("Customer-ID")).toBe("customer_id");
expect(toSafeIdentifier(" Preferred Language ")).toBe("preferred_language");
expect(toSafeIdentifier("city__name")).toBe("city_name");
});
test("strips invalid leading characters until first lowercase letter", () => {
expect(toSafeIdentifier("123 Date")).toBe("date");
expect(toSafeIdentifier("__name")).toBe("name");
expect(toSafeIdentifier("99")).toBe("");
});
test("keeps already safe identifiers unchanged", () => {
expect(toSafeIdentifier("country_code")).toBe("country_code");
});
});
});
-38
View File
@@ -12,44 +12,6 @@ export const isSafeIdentifier = (value: string): boolean => {
return /^[a-z0-9_]+$/.test(value);
};
/**
* Converts a free-form string to a safe identifier candidate.
* The output only contains lowercase letters, numbers, and underscores.
* It also ensures the identifier starts with a lowercase letter by stripping invalid leading chars.
*/
export const toSafeIdentifier = (value: string): string => {
const normalized = value.trim().toLowerCase();
let safeIdentifier = "";
let shouldInsertUnderscore = false;
for (const char of normalized) {
const isLowercaseLetter = char >= "a" && char <= "z";
const isDigit = char >= "0" && char <= "9";
if (isLowercaseLetter || isDigit) {
if (shouldInsertUnderscore && safeIdentifier.length > 0) {
safeIdentifier += "_";
}
safeIdentifier += char;
shouldInsertUnderscore = false;
continue;
}
if (safeIdentifier.length > 0) {
shouldInsertUnderscore = true;
}
}
for (let i = 0; i < safeIdentifier.length; i++) {
const char = safeIdentifier[i];
if (char >= "a" && char <= "z") {
return safeIdentifier.slice(i);
}
}
return "";
};
/**
* Converts a snake_case string to Title Case for display as a label.
* Example: "job_description" -> "Job Description"
+134 -222
View File
@@ -125,7 +125,6 @@
"activity": "Aktivität",
"add": "Hinzufügen",
"add_action": "Aktion hinzufügen",
"add_chart": "Diagramm hinzufügen",
"add_charts": "Diagramme hinzufügen",
"add_existing_chart_description": "Suche und wähle Diagramme aus, um sie zu diesem Dashboard hinzuzufügen.",
"add_filter": "Filter hinzufügen",
@@ -160,7 +159,6 @@
"change_workspace": "Workspace wechseln",
"chart": "Diagramm",
"charts": "Diagramme",
"choice_n": "Auswahl {n}",
"choices": "Entscheidungen",
"choose_organization": "Organisation auswählen",
"choose_workspace": "Projekt auswählen",
@@ -173,9 +171,8 @@
"close": "Schließen",
"code": "Code",
"collapse_rows": "Zeilen einklappen",
"column_n": "Spalte {n}",
"completed": "Abgeschlossen",
"configuration": "Konfigurieren",
"configuration": "Konfiguration",
"confirm": "Bestätigen",
"connect": "Verbinden",
"connect_formbricks": "Formbricks verbinden",
@@ -233,6 +230,7 @@
"ending_card": "Abschlusskarte",
"enter_url": "URL eingeben",
"enterprise_license": "Enterprise-Lizenz",
"environment": "Umgebung",
"error": "Fehler",
"error_component_description": "Diese Ressource existiert nicht oder du hast nicht die erforderlichen Rechte, um darauf zuzugreifen.",
"error_component_title": "Fehler beim Laden der Ressourcen",
@@ -240,11 +238,10 @@
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte versuche es später erneut.",
"error_rate_limit_title": "Rate-Limit überschritten",
"expand_rows": "Zeilen erweitern",
"failed_to_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage",
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
"failed_to_copy_to_clipboard": "Kopieren in die Zwischenablage fehlgeschlagen",
"failed_to_load_organizations": "Laden der Organisationen fehlgeschlagen",
"failed_to_load_workspaces": "Laden der Arbeitsbereiche fehlgeschlagen",
"failed_to_parse_csv": "CSV-Analyse fehlgeschlagen",
"field_placeholder": "Platzhalter für {field}",
"filter": "Filter",
"finish": "Fertig",
"first_name": "Vorname",
@@ -256,13 +253,11 @@
"generate": "Generieren",
"go_back": "Geh zurück",
"go_to_dashboard": "Zum Dashboard gehen",
"headline": "Überschrift",
"hidden": "Versteckt",
"hidden_field": "Verstecktes Feld",
"hidden_fields": "Versteckte Felder",
"hide": "Ausblenden",
"hide_column": "Spalte ausblenden",
"html": "HTML",
"id": "ID",
"image": "Bild",
"images": "Bilder",
@@ -311,6 +306,7 @@
"more_options": "Weitere Optionen",
"move_down": "Nach unten bewegen",
"move_up": "Nach oben bewegen",
"multiple_languages": "Mehrsprachigkeit",
"my_product": "mein Produkt",
"name": "Name",
"new": "Neu",
@@ -327,12 +323,10 @@
"no_result_found": "Kein Ergebnis gefunden",
"no_results": "Keine Ergebnisse",
"no_surveys_found": "Keine Umfragen gefunden.",
"no_text_found": "Kein Text gefunden",
"none_of_the_above": "Keine der oben genannten Optionen",
"none_of_the_above": "Nichts davon",
"not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.",
"not_authorized": "Nicht autorisiert",
"not_connected": "Nicht verbunden",
"not_set": "Nicht festgelegt",
"note": "Hinweis",
"notifications": "Benachrichtigungen",
"number": "Nummer",
@@ -353,7 +347,7 @@
"organization_settings": "Organisationseinstellungen",
"other": "Sonstiges",
"other_filters": "Weitere Filter",
"other_placeholder": "Sonstiger Platzhalter",
"others": "Andere",
"overlay_color": "Overlay-Farbe",
"overview": "Übersicht",
"password": "Passwort",
@@ -371,8 +365,10 @@
"please_upgrade_your_plan": "Bitte upgrade deinen Plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Vorschau",
"privacy": "Datenschutz",
"preview_survey": "Umfrage-Vorschau",
"privacy": "Datenschutzerklärung",
"product_manager": "Produktmanager",
"production": "Produktion",
"profile": "Profil",
"profile_id": "Profil-ID",
"progress": "Fortschritt",
@@ -392,22 +388,18 @@
"report_survey": "Umfrage melden",
"request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen",
"resize": "Größe ändern",
"response": "Antwort",
"response_id": "Antwort-ID",
"responses": "Antworten",
"restart": "Neu starten",
"retry": "Erneut versuchen",
"role": "Rolle",
"row_n": "Zeile {n}",
"saas": "SaaS",
"sales": "Vertrieb",
"save": "Speichern",
"save_as_draft": "Als Entwurf speichern",
"save_changes": "Änderungen speichern",
"save_without_scheduling": "Ohne Planung speichern",
"saving": "Speichert",
"scheduled": "Geplant",
"search": "Suchen",
"search_charts": "Diagramme durchsuchen...",
"security": "Sicherheit",
@@ -434,7 +426,6 @@
"some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden",
"something_went_wrong": "Etwas ist schiefgelaufen",
"something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"soon": "Bald",
"sort_by": "Sortieren nach",
"start_free_trial": "Kostenlose Testversion starten",
"status": "Status",
@@ -442,8 +433,7 @@
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
"string": "Text",
"styling": "Styling",
"subheader": "Unterüberschrift",
"submit": "Abschicken",
"submit": "Absenden",
"summary": "Zusammenfassung",
"survey": "Umfrage",
"survey_completed": "Umfrage abgeschlossen.",
@@ -451,7 +441,6 @@
"survey_languages": "Umfragesprachen",
"survey_live": "Umfrage live",
"survey_paused": "Umfrage pausiert.",
"survey_scheduled": "Umfrage geplant.",
"survey_type": "Umfragetyp",
"surveys": "Umfragen",
"table_items_deleted_successfully": "{type}s erfolgreich gelöscht",
@@ -514,6 +503,7 @@
"workspaces": "Projekte",
"years": "Jahre",
"yes": "Ja",
"you": "Du",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
"you_are_not_authorized_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion durchzuführen.",
"you_have_reached_your_limit_of_workspace_limit": "Du hast dein Limit von {workspaceLimit} Workspaces erreicht.",
@@ -531,11 +521,11 @@
"email_footer_text_2": "Dein Formbricks-Team",
"email_template_text_1": "Diese E-Mail wurde über Formbricks versendet.",
"embed_survey_preview_email_didnt_request": "Nicht angefordert?",
"embed_survey_preview_email_environment_id": "Umgebungs-ID",
"embed_survey_preview_email_fight_spam": "Hilf uns, Spam zu bekämpfen und leite diese E-Mail an hola@formbricks.com weiter",
"embed_survey_preview_email_heading": "E-Mail-Einbettung Vorschau",
"embed_survey_preview_email_subject": "Formbricks E-Mail-Umfrage Vorschau",
"embed_survey_preview_email_text": "So sieht das Code-Snippet eingebettet in einer E-Mail aus:",
"embed_survey_preview_email_workspace_id": "Workspace-ID",
"forgot_password_email_change_password": "Passwort ändern",
"forgot_password_email_did_not_request": "Falls Du das nicht angefordert hast, kannst Du diese E-Mail einfach ignorieren.",
"forgot_password_email_heading": "Passwort ändern",
@@ -638,7 +628,6 @@
"question_preview": "Fragenvorschau",
"response_already_received": "Wir haben bereits eine Antwort für diese E-Mail-Adresse erhalten.",
"response_submitted": "Es existiert bereits eine Antwort, die mit dieser Umfrage und diesem Kontakt verknüpft ist",
"scheduled": "Diese Umfrage ist geplant und wird bald live gehen.",
"survey_already_answered_heading": "Die Umfrage wurde bereits beantwortet.",
"survey_already_answered_subheading": "Du kannst diesen Link nur einmal verwenden.",
"survey_sent_to": "Umfrage gesendet an {email}",
@@ -773,15 +762,11 @@
"career_development_survey_question_6_choice_1": "Einzelner Mitarbeiter",
"career_development_survey_question_6_choice_2": "Manager",
"career_development_survey_question_6_choice_3": "Senior Manager",
"career_development_survey_question_6_choice_4": "Vizepräsident",
"career_development_survey_question_6_choice_5": "Geschäftsführung",
"career_development_survey_question_6_choice_6": "Andere",
"career_development_survey_question_6_headline": "Was beschreibt deine aktuelle Position am besten?",
"career_development_survey_question_6_subheader": "Bitte wähle eine der folgenden Optionen",
"ces": "Kundenaufwand (CES)",
"ces_description": "Customer Effort Score messen (1-5 oder 1-7)",
"ces_lower_label": "Sehr schwierig",
"ces_upper_label": "Sehr einfach",
"career_development_survey_question_6_choice_4": "Vice President",
"career_development_survey_question_6_choice_5": "Führungskraft",
"career_development_survey_question_6_choice_6": "Sonstiges",
"career_development_survey_question_6_headline": "Welche der folgenden Optionen beschreibt deine aktuelle Jobstufe am besten?",
"career_development_survey_question_6_subheader": "Bitte wähle eine der folgenden Optionen:",
"cess_survey_name": "CES-Umfrage",
"cess_survey_question_1_headline": "$[workspaceName] macht es mir leicht, [ZIEL HINZUFÜGEN]",
"cess_survey_question_1_lower_label": "Stimme überhaupt nicht zu",
@@ -841,13 +826,11 @@
"collect_feedback_question_6_headline": "Wie hast du von uns erfahren?",
"collect_feedback_question_7_headline": "Zum Abschluss würden wir gerne auf dein Feedback antworten. Bitte teile deine E-Mail-Adresse:",
"collect_feedback_question_7_placeholder": "beispiel@email.com",
"consent": "Zustimmung",
"consent_description": "Bitte um Zustimmung zu den Bedingungen, Konditionen oder der Datennutzung",
"contact_info": "Kontaktinfo",
"contact_info_description": "Bitte nach Name, Nachname, E-Mail, Telefonnummer und Firma gemeinsam fragen",
"csat": "Kundenzufriedenheit (CSAT)",
"csat_description": "Customer Satisfaction Score messen (1-5)",
"csat_lower_label": "Sehr unzufrieden",
"consent": "Einwilligung",
"consent_description": "Bitte um Zustimmung zu Bedingungen, Konditionen oder Datennutzung",
"contact_info": "Kontaktdaten",
"contact_info_description": "Frage nach Vorname, Nachname, E-Mail, Telefonnummer und Firma zusammen",
"csat_description": "Miss die Kundenzufriedenheit (CSAT) für dein Produkt oder deine Dienstleistung.",
"csat_name": "Kundenzufriedenheitswert (CSAT)",
"csat_question_10_headline": "Hast du noch weitere Anmerkungen, Fragen oder Anliegen?",
"csat_question_10_placeholder": "Gib hier deine Antwort ein…",
@@ -920,11 +903,10 @@
"csat_survey_question_1_lower_label": "Äußerst unzufrieden",
"csat_survey_question_1_upper_label": "Äußerst zufrieden",
"csat_survey_question_2_headline": "Super! Gibt es etwas, das wir tun können, um deine Erfahrung zu verbessern?",
"csat_survey_question_2_placeholder": "Tippe deine Antwort hier...",
"csat_survey_question_3_headline": "Ugh, sorry! Können wir irgendwas tun, um deine Erfahrung zu verbessern?",
"csat_survey_question_3_placeholder": "Tippe deine Antwort hier...",
"csat_upper_label": "Sehr zufrieden",
"cta_description": "Information anzeigen und Benutzer auffordern, eine bestimmte Aktion auszuführen",
"csat_survey_question_2_placeholder": "Gib hier deine Antwort ein…",
"csat_survey_question_3_headline": "Oh je, tut uns leid! Gibt es etwas, das wir tun können, um deine Erfahrung zu verbessern?",
"csat_survey_question_3_placeholder": "Gib hier deine Antwort ein…",
"cta_description": "Zeige Informationen an und fordere Nutzer zu einer bestimmten Aktion auf",
"custom_survey_description": "Erstelle eine Umfrage ohne Vorlage.",
"custom_survey_name": "Von Grund auf neu beginnen",
"custom_survey_question_1_headline": "Was möchtest du wissen?",
@@ -1089,17 +1071,12 @@
"gauge_feature_satisfaction_question_2_headline": "Was könnten wir besser machen?",
"identify_customer_goals_description": "Verstehe besser, ob deine Botschaft die richtigen Erwartungen an den Wert deines Produkts weckt.",
"identify_customer_goals_name": "Kundenziele identifizieren",
"identify_customer_goals_question_1_choice_1": "Meine Nutzerbasis tiefgehend verstehen",
"identify_customer_goals_question_1_choice_2": "Upselling-Möglichkeiten erkennen",
"identify_customer_goals_question_1_choice_3": "Das bestmögliche Produkt entwickeln",
"identify_customer_goals_question_1_choice_4": "Die Welt beherrschen, damit alle zum Frühstück Rosenkohl essen",
"identify_customer_goals_question_1_headline": "Was ist dein Hauptziel bei der Nutzung von $[workspaceName]?",
"identify_sign_up_barriers_description": "Biete einen Rabatt an, um Einblicke in Anmeldebarrieren zu gewinnen.",
"identify_sign_up_barriers_name": "Identifiziere Anmeldebarrieren",
"identify_sign_up_barriers_question_1_button_label": "Erhalte 10% Rabatt",
"identify_sign_up_barriers_question_1_headline": "Beantworte diese kurze Umfrage, erhalte 10% Rabatt!",
"identify_sign_up_barriers_question_1_html": "Du scheinst darüber nachzudenken, Dich anzumelden. Beantworte vier Fragen und erhalte 10% Rabatt auf jeden Plan.",
"identify_sign_up_barriers_question_2_headline": "Wie wahrscheinlich ist es, dass du dich für $[workspaceName] anmeldest?",
"identify_sign_up_barriers_description": "Biete einen Rabatt an, um Einblicke in Anmeldehürden zu erhalten.",
"identify_sign_up_barriers_name": "Anmeldehürden identifizieren",
"identify_sign_up_barriers_question_1_button_label": "10 % Rabatt sichern",
"identify_sign_up_barriers_question_1_headline": "Beantworte diese kurze Umfrage und erhalte 10 % Rabatt!",
"identify_sign_up_barriers_question_1_html": "Du überlegst anscheinend, dich anzumelden. Beantworte vier Fragen und erhalte 10 % Rabatt auf jeden Plan.",
"identify_sign_up_barriers_question_2_headline": "Wie wahrscheinlich ist es, dass du dich bei $[workspaceName] anmeldest?",
"identify_sign_up_barriers_question_2_lower_label": "Überhaupt nicht wahrscheinlich",
"identify_sign_up_barriers_question_2_upper_label": "Sehr wahrscheinlich",
"identify_sign_up_barriers_question_3_choice_1_label": "Hat vielleicht nicht das, was ich suche",
@@ -1167,16 +1144,14 @@
"improve_trial_conversion_question_1_headline": "Warum hast du deine Testphase beendet?",
"improve_trial_conversion_question_1_subheader": "Hilf uns, dich besser zu verstehen:",
"improve_trial_conversion_question_2_button_label": "Weiter",
"improve_trial_conversion_question_2_headline": "Schade. Was war das größte Problem bei der Nutzung von $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Weiter",
"improve_trial_conversion_question_3_headline": "Was hast du von $[workspaceName] erwartet?",
"improve_trial_conversion_question_4_button_label": "Erhalte 20% Rabatt",
"improve_trial_conversion_question_4_headline": "Das tut mir leid zu hören! Erhalte 20% Rabatt im ersten Jahr.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Wir freuen uns, dir einen 20% Rabatt auf einen Jahresplan anzubieten.</span></p>",
"improve_trial_conversion_question_2_headline": "Schade! Was war das größte Problem bei der Nutzung von $[workspaceName]?",
"improve_trial_conversion_question_4_button_label": "20% Rabatt sichern",
"improve_trial_conversion_question_4_headline": "Schade! Hol dir 20% Rabatt auf das erste Jahr.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Wir bieten dir gerne 20% Rabatt auf einen Jahresplan an.</span></p>",
"improve_trial_conversion_question_5_button_label": "Weiter",
"improve_trial_conversion_question_5_headline": "Was möchtest Du erreichen?",
"improve_trial_conversion_question_5_headline": "Was möchtest du erreichen?",
"improve_trial_conversion_question_5_subheader": "Bitte wähle eine der folgenden Optionen:",
"improve_trial_conversion_question_6_headline": "Wie löst Du dein Problem heutzutage?",
"improve_trial_conversion_question_6_headline": "Wie löst du dein Problem aktuell?",
"improve_trial_conversion_question_6_subheader": "Bitte nenne alternative Lösungen:",
"integration_setup_survey_description": "Bewerte, wie einfach Nutzer Integrationen zu deinem Produkt hinzufügen können. Finde blinde Flecken.",
"integration_setup_survey_name": "Umfrage zur Integration-Nutzung",
@@ -1640,6 +1615,7 @@
"this_action_will_be_triggered_when_the_page_is_loaded": "Diese Aktion wird ausgelöst, wenn die Seite geladen wird.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Diese Aktion wird ausgelöst, wenn der Nutzer 50 % der Seite scrollt.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Diese Aktion wird ausgelöst, wenn der Nutzer versucht, die Seite zu verlassen.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Das ist eine Code-Aktion. Bitte nimm die Änderungen in deiner Code-Basis vor.",
"time_in_seconds": "Zeit in Sekunden",
"time_in_seconds_placeholder": "z. B. 10",
"time_in_seconds_with_unit": "{seconds}s",
@@ -1686,7 +1662,7 @@
"chart_type_bar": "Balkendiagramm",
"chart_type_big_number": "Große Zahl",
"chart_type_line": "Liniendiagramm",
"chart_type_not_supported": "Diagrammtyp \"{chartType}\" wird noch nicht unterstützt",
"chart_type_not_supported": "Diagrammtyp \"{{chartType}}\" wird noch nicht unterstützt",
"chart_type_pie": "Kreisdiagramm",
"chart_updated_successfully": "Diagramm erfolgreich aktualisiert!",
"configure_description": "Ändere den Diagrammtyp und andere Einstellungen für diese Visualisierung.",
@@ -1774,7 +1750,7 @@
"no_valid_data_to_display": "Keine gültigen Daten zur Anzeige",
"not_contains": "enthält nicht",
"not_equals": "ist nicht gleich",
"open_chart": "Diagramm {name} öffnen",
"open_chart": "Diagramm {{name}} öffnen",
"open_options": "Diagrammoptionen öffnen",
"or_filter_logic": "ODER",
"original": "Original",
@@ -1785,10 +1761,8 @@
"please_select_dashboard": "Bitte wähle ein Dashboard aus",
"predefined_measures": "Vordefinierte Kennzahlen",
"preset": "Vorlage",
"preview_chart": "Vorschaudiagramm",
"query_executed_successfully": "Abfrage erfolgreich ausgeführt",
"reset_to_ai_suggestion": "Auf KI-Vorschlag zurücksetzen",
"save_and_add_to_dashboard": "Speichern und zum Dashboard hinzufügen",
"save_chart": "Diagramm speichern",
"save_chart_dialog_title": "Diagramm speichern",
"select_data_source": "Select a data source",
@@ -1797,12 +1771,11 @@
"select_field": "Feld auswählen",
"select_measures": "Metriken auswählen...",
"select_preset": "Vorlage auswählen",
"showing_first_n_of": "Zeige die ersten {n} von {count} Zeilen",
"showing_first_n_of": "Zeige die ersten {{n}} von {{count}} Zeilen",
"start_date": "Startdatum",
"time_dimension": "Zeitdimension",
"time_dimension_title": "Zeitbasierte Gruppierung hinzufügen",
"time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf.",
"update_chart": "Diagramm aktualisieren"
"time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf."
},
"dashboards": {
"add_count_charts": "{count} Diagramm(e) hinzufügen",
@@ -1813,7 +1786,6 @@
"create_dashboard": "Dashboard erstellen",
"create_dashboard_description": "Gib einen Namen für dein neues Dashboard ein.",
"create_failed": "Dashboard konnte nicht erstellt werden",
"create_new_chart": "Neues Diagramm erstellen",
"create_success": "Dashboard erfolgreich erstellt!",
"dashboard": "Dashboard",
"dashboard_delete_confirmation": "Bist du sicher, dass du dieses Dashboard löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
@@ -1828,14 +1800,12 @@
"duplicate_failed": "Dashboard konnte nicht dupliziert werden",
"duplicate_success": "Dashboard erfolgreich dupliziert!",
"failed_to_load_chart_data": "Diagrammdaten konnten nicht geladen werden",
"no_charts_available_description": "Es gibt keine Diagramme, die zu diesem Dashboard hinzugefügt werden können. Entweder existieren noch keine Diagramme oder alle vorhandenen Diagramme wurden bereits hinzugefügt. Gehe zur Diagramm-Seite, um neue Diagramme zu erstellen.",
"no_charts_to_add_message": "Keine Diagramme zum Hinzufügen zu diesem Dashboard vorhanden.",
"no_dashboards_found": "Keine Dashboards gefunden.",
"no_data_message": "Keine Daten. Es gibt derzeit keine Informationen zum Anzeigen. Füge Diagramme hinzu, um dein Dashboard zu erstellen.",
"please_enter_name": "Bitte gib einen Dashboard-Namen ein"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Sie haben keine Feedback-Datensätze, über die Sie berichten können. Richten Sie Feedbackquellen ein, um Daten in das System einzuspeisen.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Richten Sie Feedbackquellen ein"
}
},
"api_keys": {
"add_api_key": "API-Key hinzufügen",
@@ -1848,19 +1818,13 @@
"api_key_updated": "API-Schlüssel aktualisiert",
"delete_api_key_confirmation": "Alle Anwendungen, die diesen Schlüssel verwenden, können nicht mehr auf deine Formbricks-Daten zugreifen.",
"duplicate_access": "Doppelter Workspace-Zugriff ist nicht erlaubt",
"duplicate_directory_access": "Doppelter Zugriff auf Feedback-Datensatz-Verzeichnis nicht erlaubt",
"feedback_record_directory_access": "Zugriff auf Feedback-Datensatz-Verzeichnis",
"no_api_keys_yet": "Du hast noch keine API-Schlüssel",
"no_directory_permissions_found": "Keine Berechtigungen für Feedback-Datensatz-Verzeichnis gefunden",
"no_workspace_permissions_found": "Keine Workspace-Berechtigungen gefunden",
"no_env_permissions_found": "Keine Umgebungsberechtigungen gefunden",
"organization_access": "Organisations-Zugriff",
"organization_access_description": "Wähle Lese- oder Schreibrechte für organisationsweite Ressourcen aus.",
"permissions": "Berechtigungen",
"secret": "Geheimnis",
"unable_to_copy_api_key": "API-Schlüssel konnte nicht kopiert werden",
"unable_to_delete_api_key": "API-Schlüssel konnte nicht gelöscht werden",
"unknown_directory": "Unbekanntes Verzeichnis",
"unknown_workspace": "Unbekannter Arbeitsbereich",
"workspace_access": "Workspace-Zugriff"
},
"app-connection": {
@@ -1868,6 +1832,8 @@
"app_connection_description": "Verbinde deine App oder Website mit Formbricks.",
"cache_update_delay_description": "Wenn du Aktualisierungen an Umfragen, Kontakten, Aktionen oder anderen Daten vornimmst, kann es bis zu 1 Minute dauern, bis diese Änderungen in deiner lokalen App mit dem Formbricks SDK sichtbar werden.",
"cache_update_delay_title": "Änderungen werden nach ~1 Minute durch Caching übernommen",
"environment_id": "Deine Workspace-ID",
"environment_id_description": "Diese ID identifiziert diesen Formbricks-Workspace eindeutig.",
"formbricks_sdk_connected": "Formbricks SDK ist verbunden",
"formbricks_sdk_not_connected": "Formbricks SDK ist noch nicht verbunden.",
"formbricks_sdk_not_connected_description": "Füge das Formbricks SDK zu deiner Website oder App hinzu, um es mit Formbricks zu verbinden",
@@ -1879,9 +1845,7 @@
"sdk_connection_details_description": "Deine einzigartige Workspace-ID und SDK-Verbindungs-URL zur Integration von Formbricks in deine Anwendung.",
"setup_alert_description": "Folge dieser Schritt-für-Schritt-Anleitung, um deine App oder Website in unter 5 Minuten zu verbinden.",
"setup_alert_title": "So verbindest du dich",
"webapp_url": "SDK-Verbindungs-URL",
"workspace_id": "Deine Workspace-ID",
"workspace_id_description": "Diese ID identifiziert diesen Formbricks-Workspace eindeutig."
"webapp_url": "SDK-Verbindungs-URL"
},
"connect": {
"congrats": "Glückwunsch!",
@@ -1912,10 +1876,10 @@
"attribute_value_placeholder": "Attributwert",
"attributes_msg_attribute_limit_exceeded": "{count} neue(s) Attribut(e) konnten nicht erstellt werden, da das Maximum von {limit} Attributklassen überschritten würde. Bestehende Attribute wurden erfolgreich aktualisiert.",
"attributes_msg_attribute_type_validation_error": "{error} (Attribut \"{key}\" hat dataType: {dataType})",
"attributes_msg_email_already_exists": "Die E-Mail-Adresse existiert bereits für diesen Workspace und wurde nicht aktualisiert.",
"attributes_msg_email_already_exists": "Die E-Mail-Adresse existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"attributes_msg_email_or_userid_required": "Entweder E-Mail oder Benutzer-ID ist erforderlich. Die bestehenden Werte wurden beibehalten.",
"attributes_msg_new_attribute_created": "Neues Attribut \"{key}\" mit Typ \"{dataType}\" erstellt",
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diesen Workspace und wurde nicht aktualisiert.",
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
"contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
@@ -2176,6 +2140,7 @@
"duplicate_language_or_language_id": "Doppelte Sprache oder Sprach-ID",
"edit_languages": "Sprachen bearbeiten",
"identifier": "Kennung (ISO)",
"incomplete_translations": "Unvollständige Übersetzungen",
"language": "Sprache",
"language_deleted_successfully": "Sprache erfolgreich gelöscht",
"languages_updated_successfully": "Sprachen erfolgreich aktualisiert",
@@ -2185,7 +2150,8 @@
"please_select_a_language": "Bitte wähle eine Sprache aus",
"remove_language": "Sprache entfernen",
"remove_language_from_surveys_to_remove_it_from_workspace": "Bitte entferne die Sprache aus diesen Umfragen, um sie aus dem Workspace zu entfernen.",
"search_items": "Elemente suchen"
"search_items": "Elemente durchsuchen",
"translate": "Übersetzen"
},
"look": {
"add_background_color": "Hintergrundfarbe hinzufügen",
@@ -2399,7 +2365,7 @@
"most_popular": "Am beliebtesten",
"pending_change_removed": "Geplante Planänderung entfernt.",
"pending_plan_badge": "Geplant",
"pending_plan_change_description": "Dein Plan wechselt am {date} zu {plan}.",
"pending_plan_change_description": "Dein Plan wechselt am {{date}} zu {{plan}}.",
"pending_plan_change_title": "Geplante Planänderung",
"pending_plan_cta": "Geplant",
"per_month": "pro Monat",
@@ -2558,22 +2524,21 @@
"nav_label": "Feedback-Verzeichnisse",
"no_access": "Du hast keine Berechtigung, Feedback-Datensatz-Verzeichnisse zu verwalten.",
"no_connectors": "Noch keine Connectoren mit diesem Verzeichnis verknüpft.",
"pause_connectors_confirmation_description": "Wenn du diese Connectoren pausierst, werden keine neuen Datensätze mehr hinzugefügt.",
"pause_connectors_confirmation_title": "Verknüpfte Connectoren pausieren?",
"select_workspaces_placeholder": "Workspaces auswählen...",
"show_archived": "Archivierte anzeigen",
"title": "Feedback-Datensatz-Verzeichnisse",
"unarchive": "Aus Archiv wiederherstellen",
"unarchive_workspace_conflict": "Dieses Verzeichnis kann nicht wiederhergestellt werden, weil ein oder mehrere zugewiesene Workspaces archiviert sind.",
"workspace_access": "Workspace-Zugriff"
"unarchive": "Aus Archiv wiederherstellen"
},
"general": {
"ai_data_analysis_disabled_for_organization": "KI-Datenanalyse ist für diese Organisation deaktiviert.",
"ai_data_analysis_enabled": "Datenanreicherung & -analyse (KI)",
"ai_data_analysis_enabled_description": "KI nutzen, um mehr aus deinen Daten herauszuholen richte Dashboards, Diagramme, Berichte und mehr ein. Greift auf deine Erfahrungsdaten zu.",
"ai_enabled": "Formbricks KI",
"ai_enabled_description": "Verwalte KI-gestützte Funktionen für diese Organisation.",
"ai_features_not_enabled_for_organization": "KI-Funktionen sind für diese Organisation nicht aktiviert.",
"ai_instance_not_configured": "KI wird auf Instanzebene über Umgebungsvariablen konfiguriert. Bitte deine:n Administrator:in, AI_PROVIDER, AI_MODEL und die passenden Provider-Zugangsdaten zu setzen, bevor du KI-Funktionen aktivierst.",
"ai_settings_updated_successfully": "KI-Einstellungen erfolgreich aktualisiert",
"ai_smart_tools_disabled_for_organization": "KI-Smart-Tools sind für diese Organisation deaktiviert.",
"ai_smart_tools_enabled": "Smarte Funktionen (KI)",
"ai_smart_tools_enabled_description": "KI, die dir hilft, in kürzerer Zeit mehr zu erreichen. Greift niemals auf mit Formbricks gesammelte Daten zu. Wird nur verwendet, um z. B. Umfragen in andere Sprachen zu übersetzen.",
"bulk_invite_warning_description": "Im kostenlosen Tarif erhalten alle Organisationsmitglieder automatisch die Rolle „Inhaber:in“.",
@@ -2631,9 +2596,7 @@
"security_list_tip_link": "Hier anmelden.",
"share_invite_link": "Einladungslink teilen",
"share_this_link_to_let_your_organization_member_join_your_organization": "Teile diesen Link, damit deine Organisationsmitglieder deiner Organisation beitreten können:",
"test_email_sent_successfully": "Test-E-Mail erfolgreich gesendet",
"unlock_ai_features_description": "KI-gestützte Übersetzungen, smarte Tools und Datenanalyse sind in höheren Plänen verfügbar. Upgrade jetzt und bring deine Umfragen mit KI auf das nächste Level.",
"unlock_ai_features_with_a_higher_plan": "Schalte KI-Funktionen mit einem höheren Plan frei"
"test_email_sent_successfully": "Test-E-Mail erfolgreich gesendet"
},
"notifications": {
"auto_subscribe_to_new_surveys": "Automatisch bei neuen Umfragen anmelden",
@@ -2730,15 +2693,22 @@
},
"surveys": {
"all_set_time_to_create_first_survey": "Alles klar! Zeit, deine erste Umfrage zu erstellen",
"alphabetical": "alphabetisch",
"alphabetical": "Alphabetisch",
"copy_survey": "Umfrage kopieren",
"copy_survey_description": "Kopiere diese Umfrage in einen anderen Workspace",
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
"copy_survey_no_workspaces": "Es gibt keine anderen Workspaces, in die diese Umfrage kopiert werden kann.",
"copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.",
"copy_survey_success": "Umfrage erfolgreich kopiert",
"delete_survey_and_responses_warning": "Bist du sicher, dass du diese Umfrage und alle zugehörigen Antworten löschen möchtest?",
"edit": {
"activate_translations": "Übersetzungen aktivieren",
"add": "+ hinzufügen",
"add_a_delay_or_auto_close_the_survey": "Füge eine Verzögerung hinzu oder schließe die Umfrage automatisch.",
"add_a_four_digit_pin": "Füge eine vierstellige PIN hinzu",
"add_a_variable_to_calculate": "Variable hinzufügen",
"1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:",
"2_activate_translation_for_specific_languages": "2. Aktiviere die Übersetzung für bestimmte Sprachen:",
"add": "Hinzufügen +",
"add_a_delay_or_auto_close_the_survey": "Verzögerung hinzufügen oder Umfrage automatisch schließen",
"add_a_four_digit_pin": "Vierstellige PIN hinzufügen",
"add_a_variable_to_calculate": "Variable zur Berechnung hinzufügen",
"add_action_below": "Aktion unten hinzufügen",
"add_block": "Block hinzufügen",
"add_choice_below": "Auswahl unten hinzufügen",
@@ -2770,19 +2740,7 @@
"address_line_2": "Adresszeile 2",
"adjust_survey_closed_message": "„Umfrage geschlossen“-Nachricht anpassen",
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
"adjust_the_theme_in_the": "Passe das Thema an in den",
"ai_data_analysis_disabled": "KI-Datenanalyse ist für diese Organisation deaktiviert.",
"ai_features_not_enabled": "KI-Funktionen sind für diese Organisation nicht aktiviert.",
"ai_instance_not_configured": "KI ist nicht konfiguriert. Kontaktiere deinen Administrator.",
"ai_smart_tools_disabled": "KI-Smart-Tools sind für diese Organisation deaktiviert.",
"ai_translate": "Mit KI übersetzen",
"ai_translating": "Übersetze mit KI... Bitte lasse dieses Fenster geöffnet.",
"ai_translation_all_fields_populated": "Alle Felder sind bereits übersetzt",
"ai_translation_complete": "KI-Übersetzung abgeschlossen",
"ai_translation_failed": "Übersetzung fehlgeschlagen",
"ai_translation_instance_not_configured": "KI ist auf dieser Instanz nicht konfiguriert. Kontaktiere deinen Administrator.",
"ai_translation_not_available": "KI-Übersetzung ist in deinem aktuellen Plan nicht verfügbar. Upgraden, um diese Funktion freizuschalten.",
"ai_translation_not_enabled": "KI-Smart-Tools sind für diese Organisation deaktiviert. Aktiviere sie in den Organisationseinstellungen.",
"adjust_the_theme_in_the": "Passe das Theme im",
"all_are_true": "alle sind wahr",
"all_other_answers_will_continue_to": "Alle anderen Antworten werden weiterhin",
"allow_multi_select": "Mehrfachauswahl erlauben",
@@ -2796,7 +2754,7 @@
"audience": "Publikum",
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
"auto_progress_rating_and_nps": "Bewertungs- und NPS-Fragen automatisch fortsetzen",
"auto_progress_rating_and_nps_description": "Automatisches Fortschreiten bei Ein-Fragen-Blöcken. Pflichtfragen blenden „Weiter“ aus, außer wenn „Sonstiges“ ausgewählt ist.",
"auto_progress_rating_and_nps_description": "Fahre automatisch fort, sobald Befragte eine Antwort bei Bewertungs- oder NPS-Fragen auswählen. Dies gilt nur für Blöcke mit einer einzelnen Frage. Bei Pflichtfragen wird die Weiter-Schaltfläche ausgeblendet; bei optionalen Fragen bleibt sie zum Überspringen sichtbar.",
"auto_save_disabled": "Automatisches Speichern deaktiviert",
"auto_save_disabled_tooltip": "Deine Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht versehentlich aktualisiert werden.",
"auto_save_on": "Automatisches Speichern an",
@@ -2842,7 +2800,6 @@
"caution_text": "Änderungen führen zu Inkonsistenzen",
"change_anyway": "Trotzdem ändern",
"change_background": "Hintergrund ändern",
"change_default": "Standard ändern",
"change_question_type": "Fragetyp ändern",
"change_survey_type": "Wechsel des Umfragetyps wirkt sich auf bestehenden Zugriff aus",
"change_the_background_to_a_color_image_or_animation": "Ändere den Hintergrund in eine Farbe, ein Bild oder eine Animation.",
@@ -2854,11 +2811,7 @@
"choose_the_first_question_on_your_block": "Wähle die erste Frage in Deinem Block",
"choose_where_to_run_the_survey": "Wähle aus, wo die Umfrage ausgeführt werden soll.",
"city": "Stadt",
"clear_close_on_date": "Pausierungsdatum loeschen",
"clear_publish_on_date": "Veroeffentlichungsdatum loeschen",
"close_survey_on_date": "Pausierungsdatum",
"close_survey_on_response_limit": "Umfrage bei Erreichen des Antwortlimits schließen",
"code": "Code",
"close_survey_on_response_limit": "Umfrage bei Erreichen der Antwortgrenze schließen",
"color": "Farbe",
"column_used_in_logic_error": "Diese Spalte wird in der Logik von Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
"columns": "Spalten",
@@ -2880,10 +2833,9 @@
"css_selector": "CSS-Selektor",
"cta_button_label": "„CTA“-Button-Beschriftung",
"custom_hostname": "Benutzerdefinierter Hostname",
"customize_survey_logo": "Umfragelogo anpassen",
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.",
"default_language": "Standardsprache",
"customize_survey_logo": "Umfrage-Logo anpassen",
"darken_or_lighten_background_of_your_choice": "Verdunkle oder erhelle den Hintergrund deiner Wahl.",
"days_before_showing_this_survey_again": "oder mehr Tage zwischen der zuletzt angezeigten Umfrage und dem erneuten Anzeigen dieser Umfrage vergehen müssen.",
"delete_anyways": "Trotzdem löschen",
"delete_block": "Block löschen",
"delete_choice": "Auswahl löschen",
@@ -2901,8 +2853,9 @@
"dropdown": "Dropdown",
"duplicate_block": "Block duplizieren",
"duplicate_question": "Frage duplizieren",
"edit_link": "Bearbeitungslink",
"edit_recall": "Erinnerung bearbeiten",
"edit_link": "Link bearbeiten",
"edit_recall": "Recall bearbeiten",
"edit_translations": "{lang}-Übersetzungen bearbeiten",
"element_not_found": "Frage nicht gefunden",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Erlaube Teilnehmenden, die Sprache jederzeit zu wechseln. Mindestens 2 aktive Sprachen erforderlich.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Der Spam-Schutz nutzt reCAPTCHA v3, um Spam-Antworten herauszufiltern.",
@@ -3039,20 +2992,18 @@
"long_answer_toggle_description": "Erlaube Befragten, längere, mehrzeilige Antworten zu schreiben.",
"lower_label": "Untere Beschriftung",
"manage_languages": "Sprachen verwalten",
"manage_translations": "Übersetzungen verwalten",
"matrix_all_fields": "Alle Felder",
"matrix_rows": "Zeilen",
"max_file_size": "Maximale Dateigröße",
"max_file_size_limit_is": "Die maximale Dateigrößenbeschränkung beträgt",
"missing_first": "Fehlende zuerst",
"max_file_size_limit_is": "Die maximale Dateigröße beträgt",
"move_question_to_block": "Frage in Block verschieben",
"multiply": "Multiplizieren *",
"needed_for_self_hosted_cal_com_instance": "Erforderlich für eine selbst gehostete Cal.com-Instanz",
"next_block": "Nächster Block",
"next_button_label": "Beschriftung der Schaltfläche \"Weiter\"",
"no_hidden_fields_yet_add_first_one_below": "Noch keine versteckten Felder. Füge das erste unten hinzu.",
"no_images_found_for": "Keine Bilder gefunden für ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "In diesem Workspace wurden keine Umfragesprachen gefunden. Bitte füge eine hinzu, um zu starten.",
"next_button_label": "Beschriftung für \"Weiter\"-Button",
"no_hidden_fields_yet_add_first_one_below": "Noch keine versteckten Felder vorhanden. Füge unten das erste hinzu.",
"no_images_found_for": "Keine Bilder gefunden für \"{query}\"",
"no_languages_found_add_first_one_to_get_started": "Keine Sprachen gefunden. Füge die erste hinzu, um loszulegen.",
"no_option_found": "Keine Option gefunden",
"no_recall_items_found": "Keine Wiederholungselemente gefunden",
"no_variables_yet_add_first_one_below": "Noch keine Variablen vorhanden. Füge unten die erste hinzu.",
@@ -3079,14 +3030,12 @@
"please_enter_a_valid_url": "Bitte gib eine gültige URL ein (z. B. https://example.com)",
"please_set_a_survey_trigger": "Bitte lege einen Umfrage-Trigger fest",
"please_specify": "Bitte angeben",
"present_your_survey_in_multiple_languages": "Präsentiere deine Umfrage in mehreren Sprachen",
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
"prevent_double_submission_description": "Nur eine Antwort pro E-Mail-Adresse zulassen (beta)",
"prevent_double_submission": "Doppelte Einreichung verhindern",
"prevent_double_submission_description": "Nur 1 Antwort pro E-Mail-Adresse zulassen",
"progress_saved": "Fortschritt gespeichert",
"protect_survey_with_pin": "Umfrage mit PIN schützen",
"protect_survey_with_pin_description": "Nur Nutzer mit der PIN können auf die Umfrage zugreifen.",
"publish": "Veröffentlichen",
"publish_survey_on_date": "Veroeffentlichungsdatum",
"question": "Frage",
"question_deleted": "Frage gelöscht.",
"question_duplicated": "Frage dupliziert.",
@@ -3157,7 +3106,6 @@
"rows": "Zeilen",
"save_and_close": "Speichern & Schließen",
"scale": "Skala",
"schedule_survey": "Umfrage planen",
"search_for_images": "Nach Bildern suchen",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Sekunden nach dem Auslösen wird die Umfrage geschlossen, wenn keine Antwort erfolgt",
"seconds_before_showing_the_survey": "Sekunden vor dem Anzeigen der Umfrage.",
@@ -3173,9 +3121,8 @@
"seven_points": "7 Punkte",
"show_block_settings": "Block-Einstellungen anzeigen",
"show_button": "Button anzeigen",
"show_in_order": "In Reihenfolge anzeigen",
"show_language_switch": "Sprachwechsel anzeigen",
"show_multiple_times": "Begrenzte Anzahl von Malen anzeigen",
"show_language_switch": "Sprachumschalter anzeigen",
"show_multiple_times": "Eine begrenzte Anzahl von Malen anzeigen",
"show_only_once": "Nur einmal anzeigen",
"show_question_settings": "Frage-Einstellungen anzeigen",
"show_survey_maximum_of": "Umfrage maximal anzeigen",
@@ -3199,14 +3146,13 @@
"subtract": "Subtrahieren -",
"survey_closed_message_heading_required": "Füge eine Überschrift zur benutzerdefinierten Nachricht für geschlossene Umfragen hinzu.",
"survey_completed_heading": "Umfrage abgeschlossen",
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
"survey_placement": "Platzierung der Umfrage",
"survey_preview": "Umfragevorschau 👀",
"survey_styling": "Umfrage Styling",
"survey_trigger": "Auslöser der Umfrage",
"survey_will_be_closed_at_midnight_cet": "Die Umfrage wird am ausgewählten Datum um {time} in der Zeitzone {timeZone} geschlossen",
"survey_will_be_published_at_midnight_cet": "Die Umfrage wird am ausgewählten Datum um {time} in der Zeitzone {timeZone} veröffentlicht",
"survey_completed_subheading": "Diese kostenlose & quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Umfrage-Anzeigeeinstellungen",
"survey_placement": "Umfrage-Platzierung",
"survey_preview": "Umfrage-Vorschau 👀",
"survey_styling": "Umfrage-Styling",
"survey_trigger": "Umfrage-Auslöser",
"switch_multi_language_on_to_get_started": "Schalte Mehrsprachigkeit ein, um loszulegen 👉",
"target_block_not_found": "Zielblock nicht gefunden",
"targeted": "Gezielt",
"ten_points": "10 Punkte",
@@ -3214,13 +3160,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Zeige ein einziges Mal, auch wenn sie nicht antworten.",
"then": "Dann",
"this_action_will_remove_all_the_translations_from_this_survey": "Diese Aktion entfernt alle Übersetzungen aus dieser Umfrage.",
"this_will_remove_the_language_and_all_its_translations": "Dies entfernt diese Sprache und alle zugehörigen Übersetzungen aus dieser Umfrage. Diese Aktion kann nicht rückgängig gemacht werden.",
"three_points": "3 Punkte",
"times": "Zeiten",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg konsistent zu halten, kannst du",
"translated": "Übersetzt",
"trigger_survey_when_one_of_the_actions_is_fired": "Umfrage auslösen, wenn eine der Aktionen ausgeführt wird...",
"try_lollipop_or_mountain": "Versuch 'Lolli' oder 'Berge'...",
"times": "Mal",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg einheitlich zu halten, kannst du",
"trigger_survey_when_one_of_the_actions_is_fired": "Umfrage auslösen, wenn eine der Aktionen ausgeführt wird…",
"try_lollipop_or_mountain": "Probiere „Lutscher“ oder „Berg“…",
"type_field_id": "Feld-ID eingeben",
"underline": "Unterstreichen",
"unlock_targeting_description": "Richte dich an bestimmte Nutzergruppen basierend auf Attributen oder Geräteinformationen",
@@ -3292,9 +3236,8 @@
"verify_email_before_submission": "E-Mail vor dem Absenden verifizieren",
"verify_email_before_submission_description": "Lass nur Personen mit einer echten E-Mail-Adresse antworten.",
"visibility_and_recontact": "Sichtbarkeit & erneute Kontaktaufnahme",
"visibility_and_recontact_description": "Steuern Sie, wann diese Umfrage erscheinen kann und wie oft sie erneut erscheinen kann.",
"visible": "Sichtbar",
"wait": "Warte",
"visibility_and_recontact_description": "Steuere, wann diese Umfrage erscheinen kann und wie oft sie erneut angezeigt werden kann.",
"wait": "Warten",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Warte ein paar Sekunden nach dem Auslöser, bevor Du die Umfrage anzeigst",
"waiting_time_across_surveys": "Abkühlphase (umfrageübergreifend)",
"waiting_time_across_surveys_description": "Um Umfragemüdigkeit zu vermeiden, wähle aus, wie diese Umfrage mit der arbeitsbereichsweiten Abkühlphase interagiert.",
@@ -3495,16 +3438,14 @@
"configure_alerts": "Benachrichtigungen konfigurieren",
"congrats": "Glückwunsch! Deine Umfrage ist live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
"csat_satisfied": "CSAT: {percentage}% Zufrieden",
"csat_satisfied_tooltip": "{percentage}% der Befragten haben eine Bewertung von 4 oder 5 gegeben (CSAT).",
"current_count": "Aktuelle Anzahl",
"custom_range": "Benutzerdefinierter Bereich...",
"delete_all_existing_responses_and_displays": "Alle bestehenden Antworten und Anzeigen löschen",
"download_qr_code": "QR Code herunterladen",
"current_count": "Aktueller Zählerstand",
"custom_range": "Benutzerdefinierter Zeitraum…",
"delete_all_existing_responses_and_displays": "Alle vorhandenen Antworten und Anzeigen löschen",
"download_qr_code": "QR-Code herunterladen",
"downloading_qr_code": "QR-Code wird heruntergeladen",
"drop_offs": "Drop-Off Rate",
"drop_offs_tooltip": "So oft wurde die Umfrage gestartet, aber nicht abgeschlossen.",
"effort_score": "Aufwandswert",
"drop_offs": "Abbrüche",
"drop_offs_tooltip": "Anzahl der begonnenen, aber nicht abgeschlossenen Umfragen.",
"failed_to_copy_link": "Link konnte nicht kopiert werden",
"filter_added_successfully": "Filter erfolgreich hinzugefügt",
"filter_updated_successfully": "Filter erfolgreich aktualisiert",
"filtered_responses_csv": "Gefilterte Antworten (CSV)",
@@ -3556,7 +3497,6 @@
"limit": "Limit",
"no_identified_impressions": "Keine Impressionen von identifizierten Kontakten",
"no_responses_found": "Keine Antworten gefunden",
"nps_promoters_tooltip": "{percentage}% der Befragten haben eine Bewertung von 9 oder 10 gegeben (NPS-Promotoren).",
"other_values_found": "Andere Werte gefunden",
"overall": "Gesamt",
"promoters": "Promotoren",
@@ -3569,6 +3509,7 @@
"quotas_completed_tooltip": "Die Anzahl der von den Befragten abgeschlossenen Quoten.",
"reset_survey": "Umfrage zurücksetzen",
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
"satisfied": "Zufrieden",
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
"setup_integrations": "Integrationen einrichten",
@@ -3578,7 +3519,6 @@
"starts_tooltip": "Anzahl der Male, wie oft die Umfrage gestartet wurde.",
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt. {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
"survey_results": "Ergebnisse von {surveyName}",
"survey_scheduled_successfully": "Umfrage erfolgreich geplant",
"this_month": "Diesen Monat",
"this_quarter": "Dieses Quartal",
"this_year": "Dieses Jahr",
@@ -3593,6 +3533,7 @@
},
"survey_deleted_successfully": "Umfrage erfolgreich gelöscht",
"survey_duplicated_successfully": "Umfrage erfolgreich dupliziert",
"survey_duplication_error": "Umfrage konnte nicht dupliziert werden.",
"templates": {
"all_channels": "Alle Kanäle",
"all_industries": "Alle Branchen",
@@ -3627,21 +3568,16 @@
"team_settings_description": "Sieh nach, welche Teams auf diesen Workspace zugreifen können."
},
"unify": {
"add_feedback_record": "Feedback-Datensatz hinzufügen",
"add_feedback_record_description": "Erstellen Sie manuell einen Feedback-Datensatz.",
"add_feedback_source": "Feedback-Quelle hinzufügen",
"add_source": "Quelle hinzufügen",
"allowed_values": "Zulässige Werte: {values}",
"api_ingestion": "API-Erfassung",
"api_ingestion_manage_api_keys": "API-Schlüssel verwalten",
"api_ingestion_settings_description": "Sende Feedback-Datensätze über die Management-API.",
"auto_generated": "Automatisch generiert",
"change_file": "Datei ändern",
"click_load_sample_csv": "Klick auf 'Beispiel-CSV laden', um Spalten zu sehen",
"click_to_upload": "Zum Hochladen klicken",
"collected_at": "Erfasst am",
"configure_import": "Import konfigurieren",
"configure_mapping": "Mapping konfigurieren",
"connection": "Verbindung",
"connector_created_successfully": "Connector erfolgreich erstellt",
"connector_deleted_successfully": "Connector erfolgreich gelöscht",
"connector_duplicated_successfully": "Connector erfolgreich dupliziert",
@@ -3660,12 +3596,9 @@
"csv_import_duplicate_warning": "Wenn Du die Daten zweimal importierst, entstehen doppelte Einträge.",
"csv_inconsistent_columns": "Zeile {row} hat inkonsistente Spalten. Alle Zeilen müssen die gleichen Überschriften haben.",
"csv_max_records": "Maximal {max} Einträge erlaubt.",
"custom_source_type": "Benutzerdefinierter Quelltyp",
"custom_source_type_placeholder": "Geben Sie den benutzerdefinierten Quelltyp ein",
"default_connector_name_csv": "CSV-Import",
"default_connector_name_formbricks": "Formbricks-Umfrage-Verbindung",
"discard_feedback_record_changes_description": "Ihre Änderungen gehen verloren, wenn Sie diese Schublade schließen.",
"discard_feedback_record_changes_title": "Nicht gespeicherte Änderungen verwerfen?",
"deselect_all": "Alle abwählen",
"drop_a_field_here": "Ziehe ein Feld hierher",
"drop_field_or": "Feld ablegen oder",
"edit_csv_mapping": "CSV-Zuordnung bearbeiten",
@@ -3675,64 +3608,47 @@
"enum": "Aufzählung",
"failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden",
"feedback_date": "Aktuelles Datum",
"feedback_record_created_successfully": "Feedback-Datensatz erfolgreich erstellt",
"feedback_record_details": "Details zum Feedback-Datensatz",
"feedback_record_details_description": "Überprüfen und aktualisieren Sie die Felder des Feedback-Datensatzes.",
"feedback_record_directory": "Feedback-Datensatz-Verzeichnis",
"feedback_record_fields": "Feedback-Eintragsfelder",
"feedback_record_mcp": "Feedback-Datensatz MCP",
"feedback_record_updated_successfully": "Feedback-Datensatz erfolgreich aktualisiert",
"feedback_record_value_required": "Für den ausgewählten Feldtyp ist ein Wert erforderlich",
"feedback_records": "Feedback-Einträge",
"feedback_records_refreshed": "Feedback-Einträge aktualisiert",
"feedback_sources": "Feedback-Quellen",
"feedback_sources_directory_access_multiple": "Neue Datensätze aus diesen Quellen werden gespeichert in: {directoryNames}",
"feedback_sources_directory_access_single": "Neue Datensätze aus dieser Quelle werden gespeichert in: {directoryNames}",
"feedback_sources_settings_description": "Verbinde und verwalte alle Feedback-Quellen für diesen Workspace.",
"field_group_id": "Feldgruppen-ID",
"field_group_label": "Feldgruppenbezeichnung",
"field_id": "Feld-ID",
"field_label": "Feldbezeichnung",
"field_type": "Feldtyp",
"formbricks_surveys": "Formbricks-Umfragen",
"frd_cannot_be_changed": "Das Feedback-Verzeichnis kann nach der Erstellung nicht mehr geändert werden.",
"go_to_feedback_record_directories": "Zu den Verzeichnis-Einstellungen",
"historical_import_complete": "Import abgeschlossen: {successes} erfolgreich, {failures} fehlgeschlagen, {skipped} übersprungen (keine Daten)",
"import_csv_data": "Feedback importieren",
"import_feedback": "Feedback importieren",
"import_historical_responses": "Bisherige Antworten importieren",
"import_historical_responses_description": "Importiere jetzt vorhandene Antworten aus dieser Umfrage.",
"import_rows": "{count} Zeilen importieren",
"import_via_source_name": "Import über „{sourceName}“",
"importing_data": "Daten werden importiert...",
"importing_historical_data": "Historische Daten werden importiert...",
"invalid_enum_values": "Ungültige Werte in der Spalte, die {field} zugeordnet ist",
"invalid_values_found": "Gefunden: {values} (Zeilen: {rows}) {extra}",
"load_sample_csv": "Beispiel-CSV laden",
"manage_directories": "Verzeichnisse verwalten",
"manage_feedback_sources": "Feedbackquellen verwalten",
"metadata": "Metadaten",
"metadata_key": "Metadatenschlüssel",
"metadata_read_only_entries": "Schreibgeschützte Metadatenwerte (keine Zeichenfolge)",
"metadata_value": "Metadatenwert",
"missing_feedback_source_title": "Feedback-Quelle fehlt?",
"n_supported_questions": "{count} unterstützte Fragen",
"no_feedback_record_directory_available": "Diesem Workspace ist kein Feedback-Datensatz-Verzeichnis zugewiesen. Erstelle oder weise zuerst eines zu.",
"no_feedback_records": "Noch keine Feedback-Einträge vorhanden. Einträge erscheinen hier, sobald deine Konnektoren Daten senden.",
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
"no_surveys_found": "Keine Umfragen in dieser Umgebung gefunden",
"optional": "Optional",
"or_drag_and_drop": "oder per Drag & Drop",
"question_selected": "<strong>{count}</strong> Frage ausgewählt. Jede Antwort auf diese Frage wird einen neuen Feedback-Eintrag erstellen.",
"question_type_not_supported": "Dieser Fragetyp wird nicht unterstützt",
"questions_selected": "<strong>{count}</strong> Fragen ausgewählt. Jede Antwort auf diese Fragen wird einen neuen Feedback-Eintrag erstellen.",
"records_will_go_to": "Datensätze gehen an",
"refresh_feedback_records": "Feedback-Einträge aktualisieren",
"refreshing_feedback_records": "Feedback-Einträge werden aktualisiert...",
"request_feedback_source": "Quellen-Integration anfragen",
"required": "Erforderlich",
"save_changes": "Änderungen speichern",
"select_a_survey_to_see_questions": "Wähle eine Umfrage aus, um ihre Fragen zu sehen",
"select_a_value": "Wähle einen Wert aus...",
"select_all": "Alle auswählen",
"select_feedback_record_directory": "Verzeichnis auswählen",
"select_feedback_record_source_type": "Wählen Sie den Quelltyp aus",
"select_questions": "Fragen auswählen",
"select_source_type_description": "Wähle die Art der Feedback-Quelle aus, die Du verbinden möchtest.",
"select_source_type_prompt": "Wähle die Art der Feedback-Quelle aus, die Du verbinden möchtest:",
"select_survey": "Umfrage auswählen",
"select_survey_and_questions": "Umfrage & Fragen auswählen",
"select_survey_questions_description": "Wähle aus, welche Umfragefragen FeedbackRecords erstellen sollen.",
@@ -3742,31 +3658,27 @@
"showing_rows": "3 von {count} Zeilen werden angezeigt",
"source": "Quelle",
"source_connect_csv_description": "Feedback aus CSV-Dateien importieren",
"source_connect_feedback_record_mcp_description": "Sende Feedback-Datensätze über die MCP-Integration.",
"source_connect_formbricks_description": "Feedback aus Deinen Formbricks-Umfragen verbinden",
"source_fields": "Quellfelder",
"source_id": "Quell-ID",
"source_name": "Quellenname",
"source_type": "Quellentyp",
"source_type_cannot_be_changed": "Quellentyp kann nicht geändert werden",
"sources": "Quellen",
"status_active": "In Bearbeitung",
"status_completed": "Abgeschlossen",
"status_draft": "Entwurf",
"status_error": "Fehler",
"status_live_sync": "Live-Synchronisierung",
"status_paused": "Pausiert",
"status_ready": "Bereit",
"submission_id": "Einreichungs-ID",
"survey_has_no_questions": "Diese Umfrage hat keine Fragen",
"topics_and_subtopics": "Themen & Unterthemen",
"survey_import_line": "{surveyName}: {responseCount} Antworten × {questionCount} Fragen = {total} Feedback-Datensätze",
"total_feedback_records": "Gesamt: {checked} von {total} Feedback-Datensätzen ausgewählt über {surveyCount} Umfragen",
"unify_feedback": "Feedback vereinheitlichen",
"update_mapping_description": "Aktualisiere die Zuordnungskonfiguration für diese Quelle.",
"updated_at": "Aktualisiert am",
"upload_csv_data_description": "Lade eine CSV-Datei hoch, um Feedback-Daten zu importieren.",
"upload_csv_file": "CSV-Datei hochladen",
"user_identifier": "Benutzer",
"value": "Wert",
"value_boolean": "Wert (Boolescher Wert)",
"value_date": "Wert (Datum)",
"value_number": "Wert (Anzahl)",
"value_text": "Wert (Text)"
"value": "Wert"
},
"xm-templates": {
"ces": "CES",
+65 -151
View File
@@ -160,7 +160,6 @@
"change_workspace": "Change workspace",
"chart": "Chart",
"charts": "Charts",
"choice_n": "Choice {n}",
"choices": "Choices",
"choose_organization": "Choose organization",
"choose_workspace": "Choose workspace",
@@ -173,7 +172,6 @@
"close": "Close",
"code": "Code",
"collapse_rows": "Collapse rows",
"column_n": "Column {n}",
"completed": "Completed",
"configuration": "Configure",
"confirm": "Confirm",
@@ -233,6 +231,7 @@
"ending_card": "Ending card",
"enter_url": "Enter URL",
"enterprise_license": "Enterprise License",
"environment": "Environment",
"error": "Error",
"error_component_description": "This resource does not exist or you do not have the necessary rights to access it.",
"error_component_title": "Error loading resources",
@@ -244,7 +243,6 @@
"failed_to_load_organizations": "Failed to load organizations",
"failed_to_load_workspaces": "Failed to load workspaces",
"failed_to_parse_csv": "Failed to parse CSV",
"field_placeholder": "{field} Placeholder",
"filter": "Filter",
"finish": "Finish",
"first_name": "First Name",
@@ -256,13 +254,11 @@
"generate": "Generate",
"go_back": "Go Back",
"go_to_dashboard": "Go to Dashboard",
"headline": "Headline",
"hidden": "Hidden",
"hidden_field": "Hidden field",
"hidden_fields": "Hidden fields",
"hide": "Hide",
"hide_column": "Hide column",
"html": "HTML",
"id": "ID",
"image": "Image",
"images": "Images",
@@ -311,6 +307,7 @@
"more_options": "More options",
"move_down": "Move down",
"move_up": "Move up",
"multiple_languages": "Multiple languages",
"my_product": "my Product",
"name": "Name",
"new": "New",
@@ -327,12 +324,10 @@
"no_result_found": "No result found",
"no_results": "No results",
"no_surveys_found": "No surveys found.",
"no_text_found": "No text found",
"none_of_the_above": "None of the above",
"not_authenticated": "You are not authenticated to perform this action.",
"not_authorized": "Not authorized",
"not_connected": "Not Connected",
"not_set": "Not set",
"note": "Note",
"notifications": "Notifications",
"number": "Number",
@@ -353,7 +348,7 @@
"organization_settings": "Organization settings",
"other": "Other",
"other_filters": "Other Filters",
"other_placeholder": "Other Placeholder",
"others": "Others",
"overlay_color": "Overlay color",
"overview": "Overview",
"password": "Password",
@@ -371,8 +366,10 @@
"please_upgrade_your_plan": "Please upgrade your plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Preview",
"preview_survey": "Preview Survey",
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"production": "Production",
"profile": "Profile",
"profile_id": "Profile ID",
"progress": "Progress",
@@ -392,22 +389,18 @@
"report_survey": "Report Survey",
"request_trial_license": "Request trial license",
"reset_to_default": "Reset to default",
"resize": "Resize",
"response": "Response",
"response_id": "Response ID",
"responses": "Responses",
"restart": "Restart",
"retry": "Retry",
"role": "Role",
"row_n": "Row {n}",
"saas": "SaaS",
"sales": "Sales",
"save": "Save",
"save_as_draft": "Save as draft",
"save_changes": "Save changes",
"save_without_scheduling": "Save without scheduling",
"saving": "Saving",
"scheduled": "Scheduled",
"search": "Search",
"search_charts": "Search charts...",
"security": "Security",
@@ -434,7 +427,6 @@
"some_files_failed_to_upload": "Some files failed to upload",
"something_went_wrong": "Something went wrong",
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
"soon": "Soon",
"sort_by": "Sort by",
"start_free_trial": "Start free trial",
"status": "Status",
@@ -442,7 +434,6 @@
"storage_not_configured": "File storage not set up, uploads will likely fail",
"string": "Text",
"styling": "Styling",
"subheader": "Subheader",
"submit": "Submit",
"summary": "Summary",
"survey": "Survey",
@@ -451,7 +442,6 @@
"survey_languages": "Survey Languages",
"survey_live": "Survey live",
"survey_paused": "Survey paused.",
"survey_scheduled": "Survey scheduled.",
"survey_type": "Survey Type",
"surveys": "Surveys",
"table_items_deleted_successfully": "{type}s deleted successfully",
@@ -514,6 +504,7 @@
"workspaces": "Workspaces",
"years": "years",
"yes": "Yes",
"you": "You",
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
"you_have_reached_your_limit_of_workspace_limit": "You have reached your limit of {workspaceLimit} workspaces.",
@@ -531,11 +522,11 @@
"email_footer_text_2": "The Formbricks Team",
"email_template_text_1": "This email was sent via Formbricks.",
"embed_survey_preview_email_didnt_request": "Did not request this?",
"embed_survey_preview_email_environment_id": "Environment ID",
"embed_survey_preview_email_fight_spam": "Help us fight spam and forward this mail to hola@formbricks.com",
"embed_survey_preview_email_heading": "Preview Email Embed",
"embed_survey_preview_email_subject": "Formbricks Email Survey Preview",
"embed_survey_preview_email_text": "This is how the code snippet looks embedded into an email:",
"embed_survey_preview_email_workspace_id": "Workspace ID",
"forgot_password_email_change_password": "Change password",
"forgot_password_email_did_not_request": "If you did not request this, please ignore this email.",
"forgot_password_email_heading": "Change password",
@@ -638,7 +629,6 @@
"question_preview": "Question Preview",
"response_already_received": "We already received a response for this email address.",
"response_submitted": "A response linked to this survey and contact already exists",
"scheduled": "This survey is scheduled to go live soon.",
"survey_already_answered_heading": "The survey has already been answered.",
"survey_already_answered_subheading": "You can only use this link once.",
"survey_sent_to": "Survey sent to {email}",
@@ -778,10 +768,6 @@
"career_development_survey_question_6_choice_6": "Other",
"career_development_survey_question_6_headline": "Which of the following best describes your current job level?",
"career_development_survey_question_6_subheader": "Please select one of the following options:",
"ces": "Customer Effort (CES)",
"ces_description": "Measure Customer Effort Score (1-5 or 1-7)",
"ces_lower_label": "Very difficult",
"ces_upper_label": "Very easy",
"cess_survey_name": "CES Survey",
"cess_survey_question_1_headline": "$[workspaceName] makes it easy for me to [ADD GOAL]",
"cess_survey_question_1_lower_label": "Strongly disagree",
@@ -845,9 +831,7 @@
"consent_description": "Ask to agree to terms, conditions, or data usage",
"contact_info": "Contact Info",
"contact_info_description": "Ask for name, surname, email, phone number and company jointly",
"csat": "Customer Satisfaction (CSAT)",
"csat_description": "Measure Customer Satisfaction Score (1-5)",
"csat_lower_label": "Very unsatisfied",
"csat_description": "Measure the Customer Satisfaction Score of your product or service.",
"csat_name": "Customer Satisfaction Score (CSAT)",
"csat_question_10_headline": "Do you have any other comments, questions or concerns?",
"csat_question_10_placeholder": "Type your answer here…",
@@ -923,7 +907,6 @@
"csat_survey_question_2_placeholder": "Type your answer here…",
"csat_survey_question_3_headline": "Ugh, sorry! Is there anything we can do to improve your experience?",
"csat_survey_question_3_placeholder": "Type your answer here…",
"csat_upper_label": "Very satisfied",
"cta_description": "Display information and prompt users to take a specific action",
"custom_survey_description": "Create a survey without template.",
"custom_survey_name": "Start from scratch",
@@ -1089,11 +1072,6 @@
"gauge_feature_satisfaction_question_2_headline": "What is one thing we could do better?",
"identify_customer_goals_description": "Better understand if your messaging creates the right expectations of the value your product provides.",
"identify_customer_goals_name": "Identify Customer Goals",
"identify_customer_goals_question_1_choice_1": "Understand my user base deeply",
"identify_customer_goals_question_1_choice_2": "Identify upselling opportunities",
"identify_customer_goals_question_1_choice_3": "Build the best possible product",
"identify_customer_goals_question_1_choice_4": "Rule the world to make everyone breakfast brussels sprouts",
"identify_customer_goals_question_1_headline": "What is your primary goal for using $[workspaceName]?",
"identify_sign_up_barriers_description": "Offer a discount to gather insights about sign up barriers.",
"identify_sign_up_barriers_name": "Identify Sign Up Barriers",
"identify_sign_up_barriers_question_1_button_label": "Get 10% discount",
@@ -1168,8 +1146,6 @@
"improve_trial_conversion_question_1_subheader": "Help us understand you better:",
"improve_trial_conversion_question_2_button_label": "Next",
"improve_trial_conversion_question_2_headline": "Sorry to hear. What was the biggest problem using $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Next",
"improve_trial_conversion_question_3_headline": "What did you expect $[workspaceName] to do?",
"improve_trial_conversion_question_4_button_label": "Get 20% off",
"improve_trial_conversion_question_4_headline": "Sorry to hear! Get 20% off the first year.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We are happy to offer you a 20% discount on a yearly plan.</span></p>",
@@ -1640,6 +1616,7 @@
"this_action_will_be_triggered_when_the_page_is_loaded": "This action will be triggered when the page is loaded.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "This action will be triggered when the user scrolls 50% of the page.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "This action will be triggered when the user tries to leave the page.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "This is a code action. Please make changes in your code base.",
"time_in_seconds": "Time in seconds",
"time_in_seconds_placeholder": "e.g. 10",
"time_in_seconds_with_unit": "{seconds}s",
@@ -1686,7 +1663,7 @@
"chart_type_bar": "Bar Chart",
"chart_type_big_number": "Big Number",
"chart_type_line": "Line Chart",
"chart_type_not_supported": "Chart type \"{chartType}\" not yet supported",
"chart_type_not_supported": "Chart type \"{{chartType}}\" not yet supported",
"chart_type_pie": "Pie Chart",
"chart_updated_successfully": "Chart updated successfully!",
"configure_description": "Modify the chart type and other settings for this visualization.",
@@ -1774,7 +1751,7 @@
"no_valid_data_to_display": "No valid data to display",
"not_contains": "not contains",
"not_equals": "not equals",
"open_chart": "Open chart {name}",
"open_chart": "Open chart {{name}}",
"open_options": "Open chart options",
"or_filter_logic": "OR",
"original": "Original",
@@ -1785,10 +1762,8 @@
"please_select_dashboard": "Please select a dashboard",
"predefined_measures": "Predefined Measures",
"preset": "Preset",
"preview_chart": "Preview chart",
"query_executed_successfully": "Query executed successfully",
"reset_to_ai_suggestion": "Reset to AI suggestion",
"save_and_add_to_dashboard": "Save & add to dashboard",
"save_chart": "Save Chart",
"save_chart_dialog_title": "Save Chart",
"select_data_source": "Select a data source",
@@ -1797,12 +1772,11 @@
"select_field": "Select field",
"select_measures": "Select measures...",
"select_preset": "Select preset",
"showing_first_n_of": "Showing first {n} of {count} rows",
"showing_first_n_of": "Showing first {{n}} of {{count}} rows",
"start_date": "Start date",
"time_dimension": "Time Dimension",
"time_dimension_title": "Add time-based grouping",
"time_dimension_toggle_description": "Monitor trends over time.",
"update_chart": "Update chart"
"time_dimension_toggle_description": "Monitor trends over time."
},
"dashboards": {
"add_count_charts": "Add {count} chart(s)",
@@ -1828,14 +1802,12 @@
"duplicate_failed": "Failed to duplicate dashboard",
"duplicate_success": "Dashboard duplicated successfully!",
"failed_to_load_chart_data": "Failed to load chart data",
"no_charts_available_description": "No more charts available to add. Create a new one.",
"no_charts_to_add_message": "No charts to add to this dashboard.",
"no_dashboards_found": "No dashboards found.",
"no_data_message": "No Data. There is currently no information to display. Add charts to build your dashboard.",
"please_enter_name": "Please enter a dashboard name"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "You don't have Feedback Records to report on. Setup Feedback Sources to feed data into the system.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Setup feedback sources"
}
},
"api_keys": {
"add_api_key": "Add API Key",
@@ -1848,19 +1820,13 @@
"api_key_updated": "API Key updated",
"delete_api_key_confirmation": "Any applications using this key will no longer be able to access your Formbricks data.",
"duplicate_access": "Duplicate workspace access not allowed",
"duplicate_directory_access": "Duplicate feedback record directory access not allowed",
"feedback_record_directory_access": "Feedback Record Directory Access",
"no_api_keys_yet": "You do not have any API keys yet",
"no_directory_permissions_found": "No feedback record directory permissions found",
"no_workspace_permissions_found": "No Workspace permissions found",
"no_env_permissions_found": "No environment permissions found",
"organization_access": "Organization Access",
"organization_access_description": "Select read or write privileges for organization-wide resources.",
"permissions": "Permissions",
"secret": "Secret",
"unable_to_copy_api_key": "Unable to copy API key",
"unable_to_delete_api_key": "Unable to delete API Key",
"unknown_directory": "Unknown directory",
"unknown_workspace": "Unknown workspace",
"workspace_access": "Workspace Access"
},
"app-connection": {
@@ -1868,6 +1834,8 @@
"app_connection_description": "Connect your app or website to Formbricks.",
"cache_update_delay_description": "When you make updates to surveys, contacts, actions, or other data, it can take up to 1 minute for those changes to appear in your local app running the Formbricks SDK.",
"cache_update_delay_title": "Changes will be reflected after ~1 minute due to caching",
"environment_id": "Your Workspace ID",
"environment_id_description": "This id uniquely identifies this Formbricks workspace.",
"formbricks_sdk_connected": "Formbricks SDK is connected",
"formbricks_sdk_not_connected": "Formbricks SDK is not yet connected.",
"formbricks_sdk_not_connected_description": "Add the Formbricks SDK to your website or app to connect it with Formbricks",
@@ -1876,12 +1844,10 @@
"receiving_data": "Receiving data 💃🕺",
"recheck": "Re-check",
"sdk_connection_details": "SDK Connection Details",
"sdk_connection_details_description": "Your unique Workspace ID and SDK connection URL for integrating Formbricks with your application.",
"sdk_connection_details_description": "Your unique workspace ID and SDK connection URL for integrating Formbricks with your application.",
"setup_alert_description": "Follow this step-by-step tutorial to connect your app or website in under 5 minutes.",
"setup_alert_title": "How to connect",
"webapp_url": "SDK Connection URL",
"workspace_id": "Your Workspace ID",
"workspace_id_description": "This id uniquely identifies this Formbricks Workspace."
"webapp_url": "SDK Connection URL"
},
"connect": {
"congrats": "Congrats!",
@@ -1912,10 +1878,10 @@
"attribute_value_placeholder": "Attribute Value",
"attributes_msg_attribute_limit_exceeded": "Could not create {count} new attribute(s) as it would exceed the maximum limit of {limit} attribute classes. Existing attributes were updated successfully.",
"attributes_msg_attribute_type_validation_error": "{error} (attribute “{key}” has dataType: {dataType})",
"attributes_msg_email_already_exists": "The email already exists for this Workspace and was not updated.",
"attributes_msg_email_already_exists": "The email already exists for this environment and was not updated.",
"attributes_msg_email_or_userid_required": "Either email or user ID is required. The existing values were preserved.",
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
"attributes_msg_userid_already_exists": "The user ID already exists for this Workspace and was not updated.",
"attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.",
"contact_deleted_successfully": "Contact deleted successfully",
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
@@ -2176,6 +2142,7 @@
"duplicate_language_or_language_id": "Duplicate language or language ID",
"edit_languages": "Edit languages",
"identifier": "Identifier (ISO)",
"incomplete_translations": "Incomplete translations",
"language": "Language",
"language_deleted_successfully": "Language deleted successfully",
"languages_updated_successfully": "Languages updated successfully",
@@ -2185,7 +2152,8 @@
"please_select_a_language": "Please select a language",
"remove_language": "Remove Language",
"remove_language_from_surveys_to_remove_it_from_workspace": "Please remove the language from these surveys in order to remove it from the workspace.",
"search_items": "Search items"
"search_items": "Search items",
"translate": "Translate"
},
"look": {
"add_background_color": "Add background color",
@@ -2399,7 +2367,7 @@
"most_popular": "Most popular",
"pending_change_removed": "Scheduled plan change removed.",
"pending_plan_badge": "Scheduled",
"pending_plan_change_description": "Your plan will switch to {plan} on {date}.",
"pending_plan_change_description": "Your plan will switch to {{plan}} on {{date}}.",
"pending_plan_change_title": "Scheduled plan change",
"pending_plan_cta": "Scheduled",
"per_month": "per month",
@@ -2558,22 +2526,21 @@
"nav_label": "Feedback Directories",
"no_access": "You do not have permission to manage feedback record directories.",
"no_connectors": "No connectors linked to this directory yet.",
"pause_connectors_confirmation_description": "Pausing these connectors will stop new records from being added.",
"pause_connectors_confirmation_title": "Pause linked connectors?",
"select_workspaces_placeholder": "Select workspaces...",
"show_archived": "Show archived",
"title": "Feedback Record Directories",
"unarchive": "Unarchive",
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more assigned workspaces are archived.",
"workspace_access": "Workspace access"
"unarchive": "Unarchive"
},
"general": {
"ai_data_analysis_disabled_for_organization": "AI data analysis is disabled for this organization.",
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
"ai_data_analysis_enabled_description": "AI to get more out of your data, setup dashboards, charts, reports and more. Touches your experience data.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Manage AI-powered features for this organization.",
"ai_features_not_enabled_for_organization": "AI features are not enabled for this organization.",
"ai_instance_not_configured": "AI is configured at the instance level via environment variables. Ask your administrator to set AI_PROVIDER, AI_MODEL, and the matching provider credentials before enabling AI features.",
"ai_settings_updated_successfully": "AI settings updated successfully",
"ai_smart_tools_disabled_for_organization": "AI smart tools are disabled for this organization.",
"ai_smart_tools_enabled": "Smart functionality (AI)",
"ai_smart_tools_enabled_description": "AI to help you achieve more in less time. Never touches data collected with Formbricks. Only used to e.g. translate surveys to other languages.",
"bulk_invite_warning_description": "On the free plan, all organization members are always assigned the “Owner” role.",
@@ -2631,9 +2598,7 @@
"security_list_tip_link": "Sign up here.",
"share_invite_link": "Share Invite Link",
"share_this_link_to_let_your_organization_member_join_your_organization": "Share this link to let your organization member join your organization:",
"test_email_sent_successfully": "Test email sent successfully",
"unlock_ai_features_description": "AI-powered translations, smart tools, and data analysis are available on higher plans. Upgrade to supercharge your surveys with AI.",
"unlock_ai_features_with_a_higher_plan": "Unlock AI features with a higher plan"
"test_email_sent_successfully": "Test email sent successfully"
},
"notifications": {
"auto_subscribe_to_new_surveys": "Auto-subscribe to new surveys",
@@ -2731,10 +2696,17 @@
"surveys": {
"all_set_time_to_create_first_survey": "You are all set! Time to create your first survey",
"alphabetical": "Alphabetical",
"copy_survey": "Copy survey",
"copy_survey_description": "Copy this survey to another workspace",
"copy_survey_error": "Failed to copy survey",
"copy_survey_link_to_clipboard": "Copy survey link to clipboard",
"copy_survey_no_workspaces": "There are no other workspaces to copy this survey to.",
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
"copy_survey_success": "Survey copied successfully",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
"edit": {
"activate_translations": "Activate translations",
"1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:",
"2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:",
"add": "Add +",
"add_a_delay_or_auto_close_the_survey": "Add a delay or auto-close the survey",
"add_a_four_digit_pin": "Add a four digit PIN",
@@ -2771,18 +2743,6 @@
"adjust_survey_closed_message": "Adjust “Survey Closed” message",
"adjust_survey_closed_message_description": "Change the message visitors see when the survey is closed.",
"adjust_the_theme_in_the": "Adjust the theme in the",
"ai_data_analysis_disabled": "AI data analysis is disabled for this organization.",
"ai_features_not_enabled": "AI features are not enabled for this organization.",
"ai_instance_not_configured": "AI is not configured. Contact your administrator.",
"ai_smart_tools_disabled": "AI smart tools are disabled for this organization.",
"ai_translate": "Translate with AI",
"ai_translating": "Translating with AI... Please keep this modal open.",
"ai_translation_all_fields_populated": "All fields are already translated",
"ai_translation_complete": "AI translation complete",
"ai_translation_failed": "Translation failed",
"ai_translation_instance_not_configured": "AI is not configured on this instance. Contact your administrator.",
"ai_translation_not_available": "AI translation is not available on your current plan. Upgrade to unlock this feature.",
"ai_translation_not_enabled": "AI smart tools are disabled for this organization. Enable them in organization settings.",
"all_are_true": "all are true",
"all_other_answers_will_continue_to": "All other answers will continue to",
"allow_multi_select": "Allow multi-select",
@@ -2796,7 +2756,7 @@
"audience": "Audience",
"auto_close_on_inactivity": "Auto close on inactivity",
"auto_progress_rating_and_nps": "Auto-progress rating and NPS questions",
"auto_progress_rating_and_nps_description": "Auto-advance in single-question blocks. Required questions hide Next, except when \"Other\" is selected.",
"auto_progress_rating_and_nps_description": "Automatically advance when respondents select an answer on rating or NPS questions. This only applies to single-question blocks. Required questions hide the Next button; optional questions still show it for skipping.",
"auto_save_disabled": "Auto-save disabled",
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
"auto_save_on": "Auto-save on",
@@ -2842,7 +2802,6 @@
"caution_text": "Changes will lead to inconsistencies",
"change_anyway": "Change anyway",
"change_background": "Change background",
"change_default": "Change default",
"change_question_type": "Change question type",
"change_survey_type": "Switching survey type affects existing access",
"change_the_background_to_a_color_image_or_animation": "Change the background to a color, image or animation.",
@@ -2854,11 +2813,7 @@
"choose_the_first_question_on_your_block": "Choose the first question on your Block",
"choose_where_to_run_the_survey": "Choose where to run the survey.",
"city": "City",
"clear_close_on_date": "Clear close on date",
"clear_publish_on_date": "Clear publish on date",
"close_survey_on_date": "Close survey on date",
"close_survey_on_response_limit": "Close survey on response limit",
"code": "Code",
"color": "Color",
"column_used_in_logic_error": "This column is used in logic of question {questionIndex}. Please remove it from logic first.",
"columns": "Columns",
@@ -2883,7 +2838,6 @@
"customize_survey_logo": "Customize the survey logo",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"default_language": "Default language",
"delete_anyways": "Delete anyways",
"delete_block": "Delete block",
"delete_choice": "Delete choice",
@@ -2903,6 +2857,7 @@
"duplicate_question": "Duplicate question",
"edit_link": "Edit link",
"edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
"element_not_found": "Question not found",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Allow respondents to switch language at any time. Needs min. 2 active languages.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.",
@@ -3038,13 +2993,11 @@
"long_answer": "Long answer",
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
"lower_label": "Lower Label",
"manage_languages": "Manage languages",
"manage_translations": "Manage translations",
"manage_languages": "Manage Languages",
"matrix_all_fields": "All fields",
"matrix_rows": "Rows",
"max_file_size": "Max file size",
"max_file_size_limit_is": "Max file size limit is",
"missing_first": "Missing first",
"move_question_to_block": "Move question to block",
"multiply": "Multiply *",
"needed_for_self_hosted_cal_com_instance": "Needed for a self-hosted Cal.com instance",
@@ -3052,7 +3005,7 @@
"next_button_label": "“Next” button label",
"no_hidden_fields_yet_add_first_one_below": "No hidden fields yet. Add the first one below.",
"no_images_found_for": "No images found for “{query}”",
"no_languages_found_add_first_one_to_get_started": "No survey languages found in this workspace. Please add one to get started.",
"no_languages_found_add_first_one_to_get_started": "No languages found. Add the first one to get started.",
"no_option_found": "No option found",
"no_recall_items_found": "No recall items found",
"no_variables_yet_add_first_one_below": "No variables yet. Add the first one below.",
@@ -3079,14 +3032,12 @@
"please_enter_a_valid_url": "Please enter a valid URL (e.g., https://example.com)",
"please_set_a_survey_trigger": "Please set a survey trigger",
"please_specify": "Please specify",
"present_your_survey_in_multiple_languages": "Present your survey in multiple languages",
"prevent_double_submission": "Prevent double submission",
"prevent_double_submission_description": "Only allow 1 response per email address",
"progress_saved": "Progress saved",
"protect_survey_with_pin": "Protect survey with a PIN",
"protect_survey_with_pin_description": "Only users who have the PIN can access the survey.",
"publish": "Publish",
"publish_survey_on_date": "Publish survey on date",
"question": "Question",
"question_deleted": "Question deleted.",
"question_duplicated": "Question duplicated.",
@@ -3157,7 +3108,6 @@
"rows": "Rows",
"save_and_close": "Save & Close",
"scale": "Scale",
"schedule_survey": "Schedule survey",
"search_for_images": "Search for images",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "seconds after trigger the survey will be closed if no response",
"seconds_before_showing_the_survey": "seconds before showing the survey.",
@@ -3173,7 +3123,6 @@
"seven_points": "7 points",
"show_block_settings": "Show Block settings",
"show_button": "Show Button",
"show_in_order": "Show in order",
"show_language_switch": "Show language switch",
"show_multiple_times": "Show a limited number of times",
"show_only_once": "Show only once",
@@ -3205,8 +3154,7 @@
"survey_preview": "Survey Preview 👀",
"survey_styling": "Survey styling",
"survey_trigger": "Survey Trigger",
"survey_will_be_closed_at_midnight_cet": "Survey will be closed at {time} in the {timeZone} timezone on the selected date",
"survey_will_be_published_at_midnight_cet": "Survey will be published at {time} in the {timeZone} timezone on the selected date",
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
"target_block_not_found": "Target block not found",
"targeted": "Targeted",
"ten_points": "10 points",
@@ -3214,11 +3162,9 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Show a single time, even if they do not respond.",
"then": "Then",
"this_action_will_remove_all_the_translations_from_this_survey": "This action will remove all the translations from this survey.",
"this_will_remove_the_language_and_all_its_translations": "This will remove this language and all its translations from this survey. This action cannot be undone.",
"three_points": "3 points",
"times": "times",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "To keep the placement over all surveys consistent, you can",
"translated": "Translated",
"trigger_survey_when_one_of_the_actions_is_fired": "Trigger survey when one of the actions is fired…",
"try_lollipop_or_mountain": "Try “lollipop” or “mountain”…",
"type_field_id": "Type field id",
@@ -3293,7 +3239,6 @@
"verify_email_before_submission_description": "Only let people with a real email respond.",
"visibility_and_recontact": "Visibility & Recontact",
"visibility_and_recontact_description": "Control when this survey can appear and how often it can reappear.",
"visible": "Visible",
"wait": "Wait",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wait a few seconds after the trigger before showing the survey",
"waiting_time_across_surveys": "Cooldown Period (across surveys)",
@@ -3495,8 +3440,6 @@
"configure_alerts": "Configure alerts",
"congrats": "Congrats! Your survey is live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
"csat_satisfied": "CSAT: {percentage}% Satisfied",
"csat_satisfied_tooltip": "{percentage}% of respondents gave a rating of 4 or 5 (CSAT).",
"current_count": "Current count",
"custom_range": "Custom range…",
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
@@ -3504,7 +3447,7 @@
"downloading_qr_code": "Downloading QR code",
"drop_offs": "Drop-Offs",
"drop_offs_tooltip": "Number of times the survey has been started but not completed.",
"effort_score": "Effort Score",
"failed_to_copy_link": "Failed to copy link",
"filter_added_successfully": "Filter added successfully",
"filter_updated_successfully": "Filter updated successfully",
"filtered_responses_csv": "Filtered responses (CSV)",
@@ -3556,7 +3499,6 @@
"limit": "Limit",
"no_identified_impressions": "No impressions from identified contacts",
"no_responses_found": "No responses found",
"nps_promoters_tooltip": "{percentage}% of respondents gave a rating of 9 or 10 (NPS promoters).",
"other_values_found": "Other values found",
"overall": "Overall",
"promoters": "Promoters",
@@ -3569,6 +3511,7 @@
"quotas_completed_tooltip": "The number of quotas completed by the respondents.",
"reset_survey": "Reset survey",
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
"satisfied": "Satisfied",
"selected_responses_csv": "Selected responses (CSV)",
"selected_responses_excel": "Selected responses (Excel)",
"setup_integrations": "Setup integrations",
@@ -3578,7 +3521,6 @@
"starts_tooltip": "Number of times the survey has been started.",
"survey_reset_successfully": "Survey reset successfully. {responseCount} responses and {displayCount} displays were deleted.",
"survey_results": "{surveyName} Results",
"survey_scheduled_successfully": "Survey scheduled successfully",
"this_month": "This month",
"this_quarter": "This quarter",
"this_year": "This year",
@@ -3593,6 +3535,7 @@
},
"survey_deleted_successfully": "Survey deleted successfully",
"survey_duplicated_successfully": "Survey duplicated successfully",
"survey_duplication_error": "Failed to duplicate the survey.",
"templates": {
"all_channels": "All channels",
"all_industries": "All industries",
@@ -3627,21 +3570,16 @@
"team_settings_description": "See which teams can access this workspace."
},
"unify": {
"add_feedback_record": "Add feedback record",
"add_feedback_record_description": "Create a feedback record manually.",
"add_feedback_source": "Add Feedback Source",
"add_source": "Add source",
"allowed_values": "Allowed values: {values}",
"api_ingestion": "API ingestion",
"api_ingestion_manage_api_keys": "Manage API keys",
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"auto_generated": "Auto-generated",
"change_file": "Change file",
"click_load_sample_csv": "Click 'Load sample CSV' to see columns",
"click_to_upload": "Click to upload",
"collected_at": "Collected At",
"configure_import": "Configure import",
"configure_mapping": "Configure Mapping",
"connection": "Connection",
"connector_created_successfully": "Connector created successfully",
"connector_deleted_successfully": "Connector deleted successfully",
"connector_duplicated_successfully": "Connector duplicated successfully",
@@ -3660,12 +3598,9 @@
"csv_import_duplicate_warning": "Importing data twice will create duplicate records.",
"csv_inconsistent_columns": "Row {row} has inconsistent columns. All rows must have the same headers.",
"csv_max_records": "Maximum {max} records allowed.",
"custom_source_type": "Custom source type",
"custom_source_type_placeholder": "Enter custom source type",
"default_connector_name_csv": "CSV Import",
"default_connector_name_formbricks": "Formbricks Survey Connection",
"discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.",
"discard_feedback_record_changes_title": "Discard unsaved changes?",
"deselect_all": "Deselect all",
"drop_a_field_here": "Drop a field here",
"drop_field_or": "Drop field or",
"edit_csv_mapping": "Edit CSV mapping",
@@ -3675,64 +3610,47 @@
"enum": "enum",
"failed_to_load_feedback_records": "Failed to load feedback records",
"feedback_date": "Current date",
"feedback_record_created_successfully": "Feedback record created successfully",
"feedback_record_details": "Feedback record details",
"feedback_record_details_description": "Review and update feedback record fields.",
"feedback_record_directory": "Feedback Record Directory",
"feedback_record_fields": "Feedback Record Fields",
"feedback_record_mcp": "Feedback Record MCP",
"feedback_record_updated_successfully": "Feedback record updated successfully",
"feedback_record_value_required": "A value is required for the selected field type",
"feedback_records": "Feedback Records",
"feedback_records_refreshed": "Feedback records refreshed",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
"feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}",
"feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.",
"field_group_id": "Field Group ID",
"field_group_label": "Field Group Label",
"field_id": "Field ID",
"field_label": "Field Label",
"field_type": "Field Type",
"formbricks_surveys": "Formbricks Surveys",
"frd_cannot_be_changed": "Feedback directory cannot be changed after creation.",
"go_to_feedback_record_directories": "Go to directories settings",
"historical_import_complete": "Import complete: {successes} succeeded, {failures} failed, {skipped} skipped (no data)",
"import_csv_data": "Import feedback",
"import_feedback": "Import feedback",
"import_historical_responses": "Import historical responses",
"import_historical_responses_description": "Import existing responses from this survey now.",
"import_rows": "Import {count} rows",
"import_via_source_name": "Import via \"{sourceName}\"",
"importing_data": "Importing data...",
"importing_historical_data": "Importing historical data...",
"invalid_enum_values": "Invalid values in column mapped to {field}",
"invalid_values_found": "Found: {values} (rows: {rows}) {extra}",
"load_sample_csv": "Load sample CSV",
"manage_directories": "Manage directories",
"manage_feedback_sources": "Manage feedback sources",
"metadata": "Metadata",
"metadata_key": "Metadata key",
"metadata_read_only_entries": "Read-only metadata values (non-string)",
"metadata_value": "Metadata value",
"missing_feedback_source_title": "Missing feedback source?",
"n_supported_questions": "{count} supported questions",
"no_feedback_record_directory_available": "No feedback record directory assigned to this workspace. Create or assign one first.",
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
"no_source_fields_loaded": "No source fields loaded yet",
"no_sources_connected": "No sources connected yet. Add a source to get started.",
"no_surveys_found": "No surveys found in this environment",
"optional": "Optional",
"or_drag_and_drop": "or drag and drop",
"question_selected": "<strong>{count}</strong> question selected. Each response to these questions will create a new Feedback Record.",
"question_type_not_supported": "This question type is not supported",
"questions_selected": "<strong>{count}</strong> questions selected. Each response to these questions will create a new Feedback Record.",
"records_will_go_to": "Records will go to",
"refresh_feedback_records": "Refresh feedback records",
"refreshing_feedback_records": "Refreshing feedback records...",
"request_feedback_source": "Request source integration",
"required": "Required",
"save_changes": "Save changes",
"select_a_survey_to_see_questions": "Select a survey to see its questions",
"select_a_value": "Select a value...",
"select_all": "Select all",
"select_feedback_record_directory": "Select a directory",
"select_feedback_record_source_type": "Select source type",
"select_questions": "Select questions",
"select_source_type_description": "Select the type of feedback source you want to connect.",
"select_source_type_prompt": "Select the type of feedback source you want to connect:",
"select_survey": "Select Survey",
"select_survey_and_questions": "Select Survey & Questions",
"select_survey_questions_description": "Choose which survey questions should create FeedbackRecords.",
@@ -3742,31 +3660,27 @@
"showing_rows": "Showing 3 of {count} rows",
"source": "source",
"source_connect_csv_description": "Import feedback from CSV files",
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
"source_connect_formbricks_description": "Connect feedback from your Formbricks surveys",
"source_fields": "Source Fields",
"source_id": "Source ID",
"source_name": "Source Name",
"source_type": "Source Type",
"source_type_cannot_be_changed": "Source type cannot be changed",
"sources": "Sources",
"status_active": "In Progress",
"status_completed": "Completed",
"status_draft": "Draft",
"status_error": "Error",
"status_live_sync": "Live sync",
"status_paused": "Paused",
"status_ready": "Ready",
"submission_id": "Submission ID",
"survey_has_no_questions": "This survey has no questions",
"topics_and_subtopics": "Topics & Subtopics",
"survey_import_line": "{surveyName}: {responseCount} responses × {questionCount} questions = {total} Feedback Records",
"total_feedback_records": "Total: {checked} of {total} Feedback Records selected across {surveyCount} surveys",
"unify_feedback": "Unify Feedback",
"update_mapping_description": "Update the mapping configuration for this source.",
"updated_at": "Updated at",
"upload_csv_data_description": "Upload a CSV file to import feedback data.",
"upload_csv_file": "Upload CSV File",
"user_identifier": "User",
"value": "Value",
"value_boolean": "Value (Boolean)",
"value_date": "Value (Date)",
"value_number": "Value (Number)",
"value_text": "Value (Text)"
"value": "Value"
},
"xm-templates": {
"ces": "CES",
+65 -153
View File
@@ -125,7 +125,6 @@
"activity": "Actividad",
"add": "Añadir",
"add_action": "Añadir acción",
"add_chart": "Agregar gráfico",
"add_charts": "Añadir gráficos",
"add_existing_chart_description": "Busca y selecciona gráficos para añadir a este panel.",
"add_filter": "Añadir filtro",
@@ -160,7 +159,6 @@
"change_workspace": "Cambiar espacio de trabajo",
"chart": "Gráfico",
"charts": "Gráficos",
"choice_n": "Opción {n}",
"choices": "Opciones",
"choose_organization": "Elegir organización",
"choose_workspace": "Elegir proyecto",
@@ -173,9 +171,8 @@
"close": "Cerrar",
"code": "Código",
"collapse_rows": "Contraer filas",
"column_n": "Columna {n}",
"completed": "Completado",
"configuration": "Configurar",
"configuration": "Configuración",
"confirm": "Confirmar",
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
@@ -233,6 +230,7 @@
"ending_card": "Tarjeta final",
"enter_url": "Introducir URL",
"enterprise_license": "Licencia empresarial",
"environment": "Entorno",
"error": "Error",
"error_component_description": "Este recurso no existe o no tienes los derechos necesarios para acceder a él.",
"error_component_title": "Error al cargar recursos",
@@ -244,7 +242,6 @@
"failed_to_load_organizations": "Error al cargar organizaciones",
"failed_to_load_workspaces": "Error al cargar los proyectos",
"failed_to_parse_csv": "Error al analizar el CSV",
"field_placeholder": "Marcador de posición de {field}",
"filter": "Filtro",
"finish": "Finalizar",
"first_name": "Nombre",
@@ -256,13 +253,11 @@
"generate": "Generar",
"go_back": "Volver",
"go_to_dashboard": "Ir al panel de control",
"headline": "Titular",
"hidden": "Oculto",
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide": "Ocultar",
"hide_column": "Ocultar columna",
"html": "HTML",
"id": "ID",
"image": "Imagen",
"images": "Imágenes",
@@ -311,6 +306,7 @@
"more_options": "Más opciones",
"move_down": "Mover hacia abajo",
"move_up": "Mover hacia arriba",
"multiple_languages": "Múltiples idiomas",
"my_product": "mi producto",
"name": "Nombre",
"new": "Nuevo",
@@ -327,12 +323,10 @@
"no_result_found": "No se encontró resultado",
"no_results": "Sin resultados",
"no_surveys_found": "No se encontraron encuestas.",
"no_text_found": "No se encontró texto",
"none_of_the_above": "Ninguna de las anteriores",
"not_authenticated": "No estás autenticado para realizar esta acción.",
"not_authorized": "No autorizado",
"not_connected": "No conectado",
"not_set": "No establecido",
"note": "Nota",
"notifications": "Notificaciones",
"number": "Número",
@@ -353,7 +347,7 @@
"organization_settings": "Ajustes de la organización",
"other": "Otro",
"other_filters": "Otros Filtros",
"other_placeholder": "Otro marcador de posición",
"others": "Otros",
"overlay_color": "Color de superposición",
"overview": "Resumen",
"password": "Contraseña",
@@ -371,8 +365,10 @@
"please_upgrade_your_plan": "Por favor, actualiza tu plan",
"powered_by_formbricks": "Desarrollado por Formbricks",
"preview": "Vista previa",
"preview_survey": "Vista previa de la encuesta",
"privacy": "Política de privacidad",
"product_manager": "Gestor de producto",
"production": "Producción",
"profile": "Perfil",
"profile_id": "ID de perfil",
"progress": "Progreso",
@@ -392,22 +388,18 @@
"report_survey": "Reportar encuesta",
"request_trial_license": "Solicitar licencia de prueba",
"reset_to_default": "Restablecer a valores predeterminados",
"resize": "Cambiar tamaño",
"response": "Respuesta",
"response_id": "ID de respuesta",
"responses": "Respuestas",
"restart": "Reiniciar",
"retry": "Reintentar",
"role": "Rol",
"row_n": "Fila {n}",
"saas": "SaaS",
"sales": "Ventas",
"save": "Guardar",
"save_as_draft": "Guardar como borrador",
"save_changes": "Guardar cambios",
"save_without_scheduling": "Guardar sin programar",
"saving": "Guardando",
"scheduled": "Programada",
"search": "Buscar",
"search_charts": "Buscar gráficos...",
"security": "Seguridad",
@@ -434,7 +426,6 @@
"some_files_failed_to_upload": "Algunos archivos no se han podido subir",
"something_went_wrong": "Algo ha salido mal",
"something_went_wrong_please_try_again": "Algo ha salido mal. Por favor, inténtalo de nuevo.",
"soon": "Próximamente",
"sort_by": "Ordenar por",
"start_free_trial": "Iniciar prueba gratuita",
"status": "Estado",
@@ -442,7 +433,6 @@
"storage_not_configured": "Almacenamiento de archivos no configurado, es probable que fallen las subidas",
"string": "Texto",
"styling": "Estilo",
"subheader": "Subtítulo",
"submit": "Enviar",
"summary": "Resumen",
"survey": "Encuesta",
@@ -451,7 +441,6 @@
"survey_languages": "Idiomas de la encuesta",
"survey_live": "Encuesta activa",
"survey_paused": "Encuesta pausada.",
"survey_scheduled": "Encuesta programada.",
"survey_type": "Tipo de encuesta",
"surveys": "Encuestas",
"table_items_deleted_successfully": "{type}s eliminados correctamente",
@@ -514,6 +503,7 @@
"workspaces": "Proyectos",
"years": "años",
"yes": "Sí",
"you": "Tú",
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
"you_are_not_authorized_to_perform_this_action": "No tienes autorización para realizar esta acción.",
"you_have_reached_your_limit_of_workspace_limit": "Has alcanzado tu límite de {workspaceLimit} espacios de trabajo.",
@@ -531,11 +521,11 @@
"email_footer_text_2": "El equipo de Formbricks",
"email_template_text_1": "Este correo electrónico fue enviado a través de Formbricks.",
"embed_survey_preview_email_didnt_request": "¿No lo has solicitado?",
"embed_survey_preview_email_environment_id": "ID de entorno",
"embed_survey_preview_email_fight_spam": "Ayúdanos a combatir el spam y reenvía este correo a hola@formbricks.com",
"embed_survey_preview_email_heading": "Vista previa del correo electrónico incrustado",
"embed_survey_preview_email_subject": "Vista previa de la encuesta por correo electrónico de Formbricks",
"embed_survey_preview_email_text": "Así es como se ve el fragmento de código incrustado en un correo electrónico:",
"embed_survey_preview_email_workspace_id": "ID del espacio de trabajo",
"forgot_password_email_change_password": "Cambiar contraseña",
"forgot_password_email_did_not_request": "Si no has solicitado esto, por favor ignora este correo electrónico.",
"forgot_password_email_heading": "Cambiar contraseña",
@@ -638,7 +628,6 @@
"question_preview": "Vista previa de la pregunta",
"response_already_received": "Ya hemos recibido una respuesta para esta dirección de correo electrónico.",
"response_submitted": "Ya existe una respuesta vinculada a esta encuesta y contacto",
"scheduled": "Esta encuesta está programada para publicarse pronto.",
"survey_already_answered_heading": "La encuesta ya ha sido respondida.",
"survey_already_answered_subheading": "Solo puedes usar este enlace una vez.",
"survey_sent_to": "Encuesta enviada a {email}",
@@ -778,10 +767,6 @@
"career_development_survey_question_6_choice_6": "Otro",
"career_development_survey_question_6_headline": "¿Cuál de las siguientes opciones describe mejor tu nivel de trabajo actual?",
"career_development_survey_question_6_subheader": "Por favor, selecciona una de las siguientes opciones",
"ces": "Esfuerzo del Cliente (CES)",
"ces_description": "Mide la Puntuación de Esfuerzo del Cliente (1-5 o 1-7)",
"ces_lower_label": "Muy difícil",
"ces_upper_label": "Muy fácil",
"cess_survey_name": "Encuesta CES",
"cess_survey_question_1_headline": "$[workspaceName] me facilita [AÑADIR OBJETIVO]",
"cess_survey_question_1_lower_label": "Totalmente en desacuerdo",
@@ -845,9 +830,7 @@
"consent_description": "Solicitar aceptación de términos, condiciones o uso de datos",
"contact_info": "Información de contacto",
"contact_info_description": "Solicitar nombre, apellidos, correo electrónico, número de teléfono y empresa conjuntamente",
"csat": "Satisfacción del Cliente (CSAT)",
"csat_description": "Mide la Puntuación de Satisfacción del Cliente (1-5)",
"csat_lower_label": "Muy insatisfecho",
"csat_description": "Mide el índice de satisfacción del cliente de tu producto o servicio.",
"csat_name": "Índice de satisfacción del cliente (CSAT)",
"csat_question_10_headline": "¿Tienes algún otro comentario, pregunta o inquietud?",
"csat_question_10_placeholder": "Escribe tu respuesta aquí...",
@@ -923,7 +906,6 @@
"csat_survey_question_2_placeholder": "Escribe tu respuesta aquí...",
"csat_survey_question_3_headline": "Vaya, ¡lo sentimos! ¿Hay algo que podamos hacer para mejorar tu experiencia?",
"csat_survey_question_3_placeholder": "Escribe tu respuesta aquí...",
"csat_upper_label": "Muy satisfecho",
"cta_description": "Muestra información y anima a los usuarios a realizar una acción específica",
"custom_survey_description": "Crea una encuesta sin plantilla.",
"custom_survey_name": "Empezar desde cero",
@@ -1089,11 +1071,6 @@
"gauge_feature_satisfaction_question_2_headline": "¿Qué es una cosa que podríamos mejorar?",
"identify_customer_goals_description": "Comprende mejor si tus mensajes crean las expectativas correctas sobre el valor que proporciona tu producto.",
"identify_customer_goals_name": "Identificar objetivos del cliente",
"identify_customer_goals_question_1_choice_1": "Conocer a fondo mi base de usuarios",
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de venta adicional",
"identify_customer_goals_question_1_choice_3": "Construir el mejor producto posible",
"identify_customer_goals_question_1_choice_4": "Dominar el mundo para hacer que todos desayunen coles de Bruselas",
"identify_customer_goals_question_1_headline": "¿Cuál es tu objetivo principal al usar $[workspaceName]?",
"identify_sign_up_barriers_description": "Ofrece un descuento para obtener información sobre las barreras de registro.",
"identify_sign_up_barriers_name": "Identificar barreras de registro",
"identify_sign_up_barriers_question_1_button_label": "Obtener 10 % de descuento",
@@ -1168,8 +1145,6 @@
"improve_trial_conversion_question_1_subheader": "Ayúdanos a entenderte mejor:",
"improve_trial_conversion_question_2_button_label": "Siguiente",
"improve_trial_conversion_question_2_headline": "Lamentamos oírlo. ¿Cuál fue el mayor problema al usar $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Siguiente",
"improve_trial_conversion_question_3_headline": "¿Qué esperabas que hiciera $[workspaceName]?",
"improve_trial_conversion_question_4_button_label": "Obtener 20 % de descuento",
"improve_trial_conversion_question_4_headline": "¡Sentimos oírlo! Obtén un 20 % de descuento en el primer año.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Nos complace ofrecerte un 20 % de descuento en un plan anual.</span></p>",
@@ -1640,6 +1615,7 @@
"this_action_will_be_triggered_when_the_page_is_loaded": "Esta acción se activará cuando se cargue la página.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Esta acción se activará cuando el usuario desplace el 50 % de la página.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Esta acción se activará cuando el usuario intente abandonar la página.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Esta es una acción de código. Por favor, realiza cambios en tu base de código.",
"time_in_seconds": "Tiempo en segundos",
"time_in_seconds_placeholder": "p. ej. 10",
"time_in_seconds_with_unit": "{seconds}s",
@@ -1686,7 +1662,7 @@
"chart_type_bar": "Gráfico de barras",
"chart_type_big_number": "Número grande",
"chart_type_line": "Gráfico de líneas",
"chart_type_not_supported": "El tipo de gráfico \"{chartType}\" aún no está soportado",
"chart_type_not_supported": "El tipo de gráfico \"{{chartType}}\" aún no está soportado",
"chart_type_pie": "Gráfico circular",
"chart_updated_successfully": "¡Gráfico actualizado correctamente!",
"configure_description": "Modifica el tipo de gráfico y otros ajustes para esta visualización.",
@@ -1774,7 +1750,7 @@
"no_valid_data_to_display": "No hay datos válidos para mostrar",
"not_contains": "no contiene",
"not_equals": "no es igual a",
"open_chart": "Abrir gráfico {name}",
"open_chart": "Abrir gráfico {{name}}",
"open_options": "Abrir opciones del gráfico",
"or_filter_logic": "O",
"original": "Original",
@@ -1785,10 +1761,8 @@
"please_select_dashboard": "Selecciona un panel de control",
"predefined_measures": "Medidas predefinidas",
"preset": "Preajuste",
"preview_chart": "Vista previa del gráfico",
"query_executed_successfully": "Consulta ejecutada correctamente",
"reset_to_ai_suggestion": "Restablecer a sugerencia de IA",
"save_and_add_to_dashboard": "Guardar y agregar al panel",
"save_chart": "Guardar gráfico",
"save_chart_dialog_title": "Guardar gráfico",
"select_data_source": "Select a data source",
@@ -1797,12 +1771,11 @@
"select_field": "Seleccionar campo",
"select_measures": "Seleccionar medidas...",
"select_preset": "Seleccionar preajuste",
"showing_first_n_of": "Mostrando las primeras {n} de {count} filas",
"showing_first_n_of": "Mostrando las primeras {{n}} de {{count}} filas",
"start_date": "Fecha de inicio",
"time_dimension": "Dimensión temporal",
"time_dimension_title": "Añadir agrupación temporal",
"time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo.",
"update_chart": "Cuadro de actualización"
"time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo."
},
"dashboards": {
"add_count_charts": "Añadir {count} gráfico(s)",
@@ -1813,7 +1786,6 @@
"create_dashboard": "Crear panel",
"create_dashboard_description": "Introduce un nombre para tu panel de control nuevo.",
"create_failed": "Error al crear el panel de control",
"create_new_chart": "Crear nuevo gráfico",
"create_success": "Panel de control creado correctamente",
"dashboard": "Panel",
"dashboard_delete_confirmation": "¿Estás seguro de que quieres eliminar este panel? Esta acción no se puede deshacer.",
@@ -1828,14 +1800,12 @@
"duplicate_failed": "Error al duplicar el panel de control",
"duplicate_success": "Panel de control duplicado correctamente",
"failed_to_load_chart_data": "Error al cargar los datos del gráfico",
"no_charts_available_description": "No hay gráficos que se puedan añadir a este panel. O bien no existen gráficos todavía, o todos los gráficos existentes ya se han añadido. Ve a la página de Gráficos para crear nuevos gráficos.",
"no_charts_to_add_message": "No hay gráficos para añadir a este panel.",
"no_dashboards_found": "No se han encontrado paneles de control.",
"no_data_message": "Sin datos. Actualmente no hay información que mostrar. Añade gráficos para crear tu panel.",
"please_enter_name": "Por favor, introduce un nombre para el panel de control"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "No tienes registros de comentarios sobre los que informar. Configure fuentes de comentarios para introducir datos en el sistema.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Configurar fuentes de comentarios"
}
},
"api_keys": {
"add_api_key": "Añadir clave API",
@@ -1848,19 +1818,13 @@
"api_key_updated": "Clave API actualizada",
"delete_api_key_confirmation": "Cualquier aplicación que use esta clave ya no podrá acceder a tus datos de Formbricks.",
"duplicate_access": "No se permite el acceso duplicado al espacio de trabajo",
"duplicate_directory_access": "No se permite el acceso duplicado al directorio de registros de feedback",
"feedback_record_directory_access": "Acceso al Directorio de Registros de Feedback",
"no_api_keys_yet": "Aún no tienes ninguna clave API",
"no_directory_permissions_found": "No se encontraron permisos de directorio de registros de feedback",
"no_workspace_permissions_found": "No se encontraron permisos del espacio de trabajo",
"no_env_permissions_found": "No se encontraron permisos de entorno",
"organization_access": "Acceso a la organización",
"organization_access_description": "Selecciona privilegios de lectura o escritura para recursos de toda la organización.",
"permissions": "Permisos",
"secret": "Secreto",
"unable_to_copy_api_key": "No se puede copiar la clave de API",
"unable_to_delete_api_key": "No se puede eliminar la clave API",
"unknown_directory": "Directorio desconocido",
"unknown_workspace": "Espacio de trabajo desconocido",
"workspace_access": "Acceso al espacio de trabajo"
},
"app-connection": {
@@ -1868,6 +1832,8 @@
"app_connection_description": "Conecta tu aplicación o sitio web a Formbricks.",
"cache_update_delay_description": "Cuando realizas actualizaciones en encuestas, contactos, acciones u otros datos, puede tardar hasta 1 minuto en que esos cambios aparezcan en tu aplicación local que ejecuta el SDK de Formbricks.",
"cache_update_delay_title": "Los cambios se reflejarán después de ~1 minuto debido al almacenamiento en caché",
"environment_id": "ID de tu espacio de trabajo",
"environment_id_description": "Este ID identifica de forma única este espacio de trabajo de Formbricks.",
"formbricks_sdk_connected": "El SDK de Formbricks está conectado",
"formbricks_sdk_not_connected": "El SDK de Formbricks aún no está conectado.",
"formbricks_sdk_not_connected_description": "Añade el SDK de Formbricks a tu sitio web o aplicación para conectarlo con Formbricks",
@@ -1876,12 +1842,10 @@
"receiving_data": "Recibiendo datos 💃🕺",
"recheck": "Volver a comprobar",
"sdk_connection_details": "Detalles de conexión del SDK",
"sdk_connection_details_description": "Tu ID único de espacio de trabajo y URL de conexión del SDK para integrar Formbricks con tu aplicación.",
"sdk_connection_details_description": "Tu ID de espacio de trabajo único y la URL de conexión del SDK para integrar Formbricks con tu aplicación.",
"setup_alert_description": "Sigue este tutorial paso a paso para conectar tu aplicación o sitio web en menos de 5 minutos.",
"setup_alert_title": "Cómo conectar",
"webapp_url": "URL de conexión del SDK",
"workspace_id": "ID de tu espacio de trabajo",
"workspace_id_description": "Este ID identifica de forma única este espacio de trabajo de Formbricks."
"webapp_url": "URL de conexión del SDK"
},
"connect": {
"congrats": "¡Enhorabuena!",
@@ -1912,10 +1876,10 @@
"attribute_value_placeholder": "Valor del atributo",
"attributes_msg_attribute_limit_exceeded": "No se pudieron crear {count} atributo(s) nuevo(s) ya que se excedería el límite máximo de {limit} clases de atributos. Los atributos existentes se actualizaron correctamente.",
"attributes_msg_attribute_type_validation_error": "{error} (el atributo “{key}” tiene dataType: {dataType})",
"attributes_msg_email_already_exists": "El correo electrónico ya existe para este espacio de trabajo y no se actualizó.",
"attributes_msg_email_already_exists": "El email ya existe para este entorno y no se actualizó.",
"attributes_msg_email_or_userid_required": "Se requiere el correo electrónico o el ID de usuario. Se conservaron los valores existentes.",
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”",
"attributes_msg_userid_already_exists": "El ID de usuario ya existe para este espacio de trabajo y no se actualizó.",
"attributes_msg_userid_already_exists": "El ID de usuario ya existe para este entorno y no se actualizó.",
"contact_deleted_successfully": "Contacto eliminado correctamente",
"contacts_table_refresh": "Actualizar contactos",
"contacts_table_refresh_success": "Contactos actualizados correctamente",
@@ -2176,6 +2140,7 @@
"duplicate_language_or_language_id": "Idioma o ID de idioma duplicado",
"edit_languages": "Editar idiomas",
"identifier": "Identificador (ISO)",
"incomplete_translations": "Traducciones incompletas",
"language": "Idioma",
"language_deleted_successfully": "Idioma eliminado correctamente",
"languages_updated_successfully": "Idiomas actualizados correctamente",
@@ -2185,7 +2150,8 @@
"please_select_a_language": "Por favor, selecciona un idioma",
"remove_language": "Eliminar idioma",
"remove_language_from_surveys_to_remove_it_from_workspace": "Por favor, elimina el idioma de estas encuestas para poder eliminarlo del espacio de trabajo.",
"search_items": "Buscar elementos"
"search_items": "Buscar elementos",
"translate": "Traducir"
},
"look": {
"add_background_color": "Añadir color de fondo",
@@ -2399,7 +2365,7 @@
"most_popular": "Más popular",
"pending_change_removed": "Cambio de plan programado eliminado.",
"pending_plan_badge": "Programado",
"pending_plan_change_description": "Tu plan cambiará a {plan} el {date}.",
"pending_plan_change_description": "Tu plan cambiará a {{plan}} el {{date}}.",
"pending_plan_change_title": "Cambio de plan programado",
"pending_plan_cta": "Programado",
"per_month": "por mes",
@@ -2558,22 +2524,21 @@
"nav_label": "Directorios de Feedback",
"no_access": "No tienes permiso para gestionar los directorios de registros de feedback.",
"no_connectors": "Aún no hay conectores vinculados a este directorio.",
"pause_connectors_confirmation_description": "Si pausas estos conectores, no se añadirán nuevos registros.",
"pause_connectors_confirmation_title": "¿Pausar conectores vinculados?",
"select_workspaces_placeholder": "Selecciona espacios de trabajo...",
"show_archived": "Mostrar archivados",
"title": "Directorios de Registros de Feedback",
"unarchive": "Desarchivar",
"unarchive_workspace_conflict": "No se puede desarchivar este directorio porque uno o más espacios de trabajo asignados están archivados.",
"workspace_access": "Acceso al espacio de trabajo"
"unarchive": "Desarchivar"
},
"general": {
"ai_data_analysis_disabled_for_organization": "El análisis y enriquecimiento de datos con IA está deshabilitado para esta organización.",
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
"ai_data_analysis_enabled_description": "IA para sacar más partido a tus datos, configurar paneles, gráficos, informes y más. Accede a los datos de experiencia.",
"ai_enabled": "IA de Formbricks",
"ai_enabled_description": "Gestiona las funciones impulsadas por IA para esta organización.",
"ai_features_not_enabled_for_organization": "Las funciones de IA no están habilitadas para esta organización.",
"ai_instance_not_configured": "La IA se configura a nivel de instancia mediante variables de entorno. Pide a tu administrador que configure AI_PROVIDER, las credenciales de ese proveedor y la lista de modelos correspondiente antes de habilitar las funciones de IA.",
"ai_settings_updated_successfully": "Configuración de IA actualizada correctamente",
"ai_smart_tools_disabled_for_organization": "Las herramientas inteligentes de IA están deshabilitadas para esta organización.",
"ai_smart_tools_enabled": "Funcionalidad inteligente (IA)",
"ai_smart_tools_enabled_description": "IA para ayudarte a conseguir más en menos tiempo. Nunca accede a los datos recopilados con Formbricks. Solo se usa para, por ejemplo, traducir encuestas a otros idiomas.",
"bulk_invite_warning_description": "En el plan gratuito, a todos los miembros de la organización se les asigna siempre el rol de \"Propietario\".",
@@ -2631,9 +2596,7 @@
"security_list_tip_link": "Regístrate aquí.",
"share_invite_link": "Compartir enlace de invitación",
"share_this_link_to_let_your_organization_member_join_your_organization": "Comparte este enlace para permitir que los miembros de tu organización se unan a tu organización:",
"test_email_sent_successfully": "Correo electrónico de prueba enviado correctamente",
"unlock_ai_features_description": "Las traducciones impulsadas por IA, herramientas inteligentes y análisis de datos están disponibles en planes superiores. Mejora tu plan para potenciar tus encuestas con IA.",
"unlock_ai_features_with_a_higher_plan": "Desbloquea funciones de IA con un plan superior"
"test_email_sent_successfully": "Correo electrónico de prueba enviado correctamente"
},
"notifications": {
"auto_subscribe_to_new_surveys": "Suscripción automática a nuevas encuestas",
@@ -2731,10 +2694,17 @@
"surveys": {
"all_set_time_to_create_first_survey": "¡Todo listo! Es hora de crear tu primera encuesta",
"alphabetical": "Alfabético",
"copy_survey": "Copiar encuesta",
"copy_survey_description": "Copia esta encuesta a otro espacio de trabajo",
"copy_survey_error": "Error al copiar la encuesta",
"copy_survey_link_to_clipboard": "Copiar enlace de la encuesta al portapapeles",
"copy_survey_no_workspaces": "No hay otros espacios de trabajo a los que copiar esta encuesta.",
"copy_survey_partially_success": "{success} encuestas copiadas correctamente, {error} fallidas.",
"copy_survey_success": "¡Encuesta copiada correctamente!",
"delete_survey_and_responses_warning": "¿Estás seguro de que quieres eliminar esta encuesta y todas sus respuestas?",
"edit": {
"activate_translations": "Activar traducciones",
"1_choose_the_default_language_for_this_survey": "1. Elige el idioma predeterminado para esta encuesta:",
"2_activate_translation_for_specific_languages": "2. Activa la traducción para idiomas específicos:",
"add": "Añadir +",
"add_a_delay_or_auto_close_the_survey": "Añadir un retraso o cerrar automáticamente la encuesta",
"add_a_four_digit_pin": "Añadir un PIN de cuatro dígitos",
@@ -2771,18 +2741,6 @@
"adjust_survey_closed_message": "Ajustar mensaje 'Encuesta cerrada'",
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
"adjust_the_theme_in_the": "Ajustar el tema en el",
"ai_data_analysis_disabled": "El análisis de datos con IA está deshabilitado para esta organización.",
"ai_features_not_enabled": "Las funciones de IA no están habilitadas para esta organización.",
"ai_instance_not_configured": "La IA no está configurada. Contacta con tu administrador.",
"ai_smart_tools_disabled": "Las herramientas inteligentes de IA están deshabilitadas para esta organización.",
"ai_translate": "Traducir con IA",
"ai_translating": "Traduciendo con IA... Por favor, mantén este modal abierto.",
"ai_translation_all_fields_populated": "Todos los campos ya están traducidos",
"ai_translation_complete": "Traducción con IA completada",
"ai_translation_failed": "La traducción ha fallado",
"ai_translation_instance_not_configured": "La IA no está configurada en esta instancia. Contacta con tu administrador.",
"ai_translation_not_available": "La traducción por IA no está disponible en tu plan actual. Mejora tu plan para desbloquear esta función.",
"ai_translation_not_enabled": "Las herramientas inteligentes de IA están desactivadas para esta organización. Actívalas en la configuración de la organización.",
"all_are_true": "todas son verdaderas",
"all_other_answers_will_continue_to": "Todas las demás respuestas continuarán",
"allow_multi_select": "Permitir selección múltiple",
@@ -2796,7 +2754,7 @@
"audience": "Audiencia",
"auto_close_on_inactivity": "Cierre automático por inactividad",
"auto_progress_rating_and_nps": "Avanzar automáticamente en preguntas de valoración y NPS",
"auto_progress_rating_and_nps_description": "Avance automático en bloques de una sola pregunta. Las preguntas obligatorias ocultan el botón Siguiente, excepto cuando se selecciona \"Otro\".",
"auto_progress_rating_and_nps_description": "Avanza automáticamente cuando los encuestados seleccionen una respuesta en preguntas de valoración o NPS. Esto solo se aplica a bloques de una sola pregunta. Las preguntas obligatorias ocultan el botón Siguiente; las preguntas opcionales aún lo muestran para omitirlas.",
"auto_save_disabled": "Guardado automático desactivado",
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
"auto_save_on": "Guardado automático activado",
@@ -2842,7 +2800,6 @@
"caution_text": "Los cambios provocarán inconsistencias",
"change_anyway": "Cambiar de todos modos",
"change_background": "Cambiar fondo",
"change_default": "Cambiar predeterminado",
"change_question_type": "Cambiar tipo de pregunta",
"change_survey_type": "Cambiar el tipo de encuesta afecta al acceso existente",
"change_the_background_to_a_color_image_or_animation": "Cambiar el fondo a un color, imagen o animación.",
@@ -2854,11 +2811,7 @@
"choose_the_first_question_on_your_block": "Elige la primera pregunta en tu bloque",
"choose_where_to_run_the_survey": "Elige dónde ejecutar la encuesta.",
"city": "Ciudad",
"clear_close_on_date": "Borrar fecha de pausa",
"clear_publish_on_date": "Borrar fecha de publicación",
"close_survey_on_date": "Fecha de pausa",
"close_survey_on_response_limit": "Cerrar encuesta al alcanzar el límite de respuestas",
"code": "Código",
"color": "Color",
"column_used_in_logic_error": "Esta columna se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
"columns": "Columnas",
@@ -2883,7 +2836,6 @@
"customize_survey_logo": "Personalizar el logotipo de la encuesta",
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
"days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.",
"default_language": "Idioma predeterminado",
"delete_anyways": "Eliminar de todos modos",
"delete_block": "Eliminar bloque",
"delete_choice": "Eliminar opción",
@@ -2903,6 +2855,7 @@
"duplicate_question": "Duplicar pregunta",
"edit_link": "Editar enlace",
"edit_recall": "Editar recuperación",
"edit_translations": "Editar traducciones de {lang}",
"element_not_found": "Pregunta no encontrada",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir a los participantes cambiar el idioma de la encuesta en cualquier momento durante la encuesta.",
"enable_recaptcha_to_protect_your_survey_from_spam": "La protección contra spam utiliza reCAPTCHA v3 para filtrar las respuestas spam.",
@@ -3039,12 +2992,10 @@
"long_answer_toggle_description": "Permitir a los encuestados escribir respuestas más largas y de varias líneas.",
"lower_label": "Etiqueta inferior",
"manage_languages": "Gestionar idiomas",
"manage_translations": "Gestionar traducciones",
"matrix_all_fields": "Todos los campos",
"matrix_rows": "Filas",
"max_file_size": "Tamaño máximo de archivo",
"max_file_size_limit_is": "El límite de tamaño máximo de archivo es",
"missing_first": "Faltantes primero",
"move_question_to_block": "Mover pregunta al bloque",
"multiply": "Multiplicar *",
"needed_for_self_hosted_cal_com_instance": "Necesario para una instancia Cal.com autohospedada",
@@ -3052,7 +3003,7 @@
"next_button_label": "Etiqueta del botón \"Siguiente\"",
"no_hidden_fields_yet_add_first_one_below": "Aún no hay campos ocultos. Añade el primero a continuación.",
"no_images_found_for": "No se encontraron imágenes para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "No se encontraron idiomas de encuesta en este espacio de trabajo. Por favor, añade uno para comenzar.",
"no_languages_found_add_first_one_to_get_started": "No se encontraron idiomas. Añade el primero para comenzar.",
"no_option_found": "No se encontró ninguna opción",
"no_recall_items_found": "No se encontraron elementos de recuperación",
"no_variables_yet_add_first_one_below": "No hay variables todavía. Añade la primera a continuación.",
@@ -3079,14 +3030,12 @@
"please_enter_a_valid_url": "Por favor, introduce una URL válida (p. ej., https://example.com)",
"please_set_a_survey_trigger": "Establece un disparador de encuesta",
"please_specify": "Por favor, especifica",
"present_your_survey_in_multiple_languages": "Presenta tu encuesta en varios idiomas",
"prevent_double_submission": "Evitar envío duplicado",
"prevent_double_submission_description": "Permitir solo 1 respuesta por dirección de correo electrónico",
"progress_saved": "Progreso guardado",
"protect_survey_with_pin": "Proteger encuesta con un PIN",
"protect_survey_with_pin_description": "Solo los usuarios que tengan el PIN pueden acceder a la encuesta.",
"publish": "Publicar",
"publish_survey_on_date": "Fecha de publicación",
"question": "Pregunta",
"question_deleted": "Pregunta eliminada.",
"question_duplicated": "Pregunta duplicada.",
@@ -3157,7 +3106,6 @@
"rows": "Filas",
"save_and_close": "Guardar y cerrar",
"scale": "Escala",
"schedule_survey": "Programar encuesta",
"search_for_images": "Buscar imágenes",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos después de activarse, la encuesta se cerrará si no hay respuesta",
"seconds_before_showing_the_survey": "segundos antes de mostrar la encuesta.",
@@ -3173,7 +3121,6 @@
"seven_points": "7 puntos",
"show_block_settings": "Mostrar ajustes del bloque",
"show_button": "Mostrar botón",
"show_in_order": "Mostrar en orden",
"show_language_switch": "Mostrar cambio de idioma",
"show_multiple_times": "Mostrar un número limitado de veces",
"show_only_once": "Mostrar solo una vez",
@@ -3205,8 +3152,7 @@
"survey_preview": "Vista previa de la encuesta 👀",
"survey_styling": "Estilo del formulario",
"survey_trigger": "Activador de la encuesta",
"survey_will_be_closed_at_midnight_cet": "La encuesta se cerrará a las {time} en la zona horaria {timeZone} en la fecha seleccionada",
"survey_will_be_published_at_midnight_cet": "La encuesta se publicará a las {time} en la zona horaria {timeZone} en la fecha seleccionada",
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
"target_block_not_found": "Bloque objetivo no encontrado",
"targeted": "Dirigido",
"ten_points": "10 puntos",
@@ -3214,11 +3160,9 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar una sola vez, incluso si no responden.",
"then": "Entonces",
"this_action_will_remove_all_the_translations_from_this_survey": "Esta acción eliminará todas las traducciones de esta encuesta.",
"this_will_remove_the_language_and_all_its_translations": "Esto eliminará este idioma y todas sus traducciones de esta encuesta. Esta acción no se puede deshacer.",
"three_points": "3 puntos",
"times": "veces",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para mantener la ubicación coherente en todas las encuestas, puedes",
"translated": "Traducido",
"trigger_survey_when_one_of_the_actions_is_fired": "Activar encuesta cuando se dispare una de las acciones...",
"try_lollipop_or_mountain": "Prueba 'piruleta' o 'montaña'...",
"type_field_id": "Escribe el id del campo",
@@ -3293,7 +3237,6 @@
"verify_email_before_submission_description": "Solo permite responder a personas con un correo electrónico real.",
"visibility_and_recontact": "Visibilidad y recontacto",
"visibility_and_recontact_description": "Controla cuándo puede aparecer esta encuesta y con qué frecuencia puede volver a aparecer.",
"visible": "Visible",
"wait": "Esperar",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Esperar unos segundos después del disparador antes de mostrar la encuesta",
"waiting_time_across_surveys": "Periodo de espera (entre encuestas)",
@@ -3495,8 +3438,6 @@
"configure_alerts": "Configurar alertas",
"congrats": "¡Enhorabuena! Tu encuesta está activa.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecta tu sitio web o aplicación con Formbricks para comenzar.",
"csat_satisfied": "CSAT: {percentage}% Satisfechos",
"csat_satisfied_tooltip": "El {percentage}% de los encuestados dieron una puntuación de 4 o 5 (CSAT).",
"current_count": "Recuento actual",
"custom_range": "Rango personalizado...",
"delete_all_existing_responses_and_displays": "Eliminar todas las respuestas y visualizaciones existentes",
@@ -3504,7 +3445,7 @@
"downloading_qr_code": "Descargando código QR",
"drop_offs": "Abandonos",
"drop_offs_tooltip": "Número de veces que se ha iniciado la encuesta pero no se ha completado.",
"effort_score": "Puntuación de Esfuerzo",
"failed_to_copy_link": "Error al copiar el enlace",
"filter_added_successfully": "Filtro añadido correctamente",
"filter_updated_successfully": "Filtro actualizado correctamente",
"filtered_responses_csv": "Respuestas filtradas (CSV)",
@@ -3556,7 +3497,6 @@
"limit": "Límite",
"no_identified_impressions": "No hay impresiones de contactos identificados",
"no_responses_found": "No se han encontrado respuestas",
"nps_promoters_tooltip": "El {percentage}% de los encuestados dieron una puntuación de 9 o 10 (promotores NPS).",
"other_values_found": "Otros valores encontrados",
"overall": "General",
"promoters": "Promotores",
@@ -3569,6 +3509,7 @@
"quotas_completed_tooltip": "El número de cuotas completadas por los encuestados.",
"reset_survey": "Reiniciar encuesta",
"reset_survey_warning": "Reiniciar una encuesta elimina todas las respuestas y visualizaciones asociadas a esta encuesta. Esto no se puede deshacer.",
"satisfied": "Satisfecho",
"selected_responses_csv": "Respuestas seleccionadas (CSV)",
"selected_responses_excel": "Respuestas seleccionadas (Excel)",
"setup_integrations": "Configurar integraciones",
@@ -3578,7 +3519,6 @@
"starts_tooltip": "Número de veces que se ha iniciado la encuesta.",
"survey_reset_successfully": "¡Encuesta restablecida correctamente! Se eliminaron {responseCount} respuestas y {displayCount} visualizaciones.",
"survey_results": "Resultados de {surveyName}",
"survey_scheduled_successfully": "Encuesta programada correctamente",
"this_month": "Este mes",
"this_quarter": "Este trimestre",
"this_year": "Este año",
@@ -3593,6 +3533,7 @@
},
"survey_deleted_successfully": "¡Encuesta eliminada correctamente!",
"survey_duplicated_successfully": "Encuesta duplicada correctamente.",
"survey_duplication_error": "Error al duplicar la encuesta.",
"templates": {
"all_channels": "Todos los canales",
"all_industries": "Todas las industrias",
@@ -3627,21 +3568,16 @@
"team_settings_description": "Consulta qué equipos pueden acceder a este espacio de trabajo."
},
"unify": {
"add_feedback_record": "Agregar registro de comentarios",
"add_feedback_record_description": "Cree un registro de comentarios manualmente.",
"add_feedback_source": "Añadir fuente de feedback",
"add_source": "Añadir fuente",
"allowed_values": "Valores permitidos: {values}",
"api_ingestion": "Ingesta de API",
"api_ingestion_manage_api_keys": "Gestionar claves de API",
"api_ingestion_settings_description": "Envía registros de feedback mediante la API de gestión.",
"auto_generated": "Generado automáticamente",
"change_file": "Cambiar archivo",
"click_load_sample_csv": "Haz clic en 'Cargar CSV de muestra' para ver las columnas",
"click_to_upload": "Haz clic para subir",
"collected_at": "Recopilado el",
"configure_import": "Configurar importación",
"configure_mapping": "Configurar asignación",
"connection": "Conexión",
"connector_created_successfully": "Conector creado correctamente",
"connector_deleted_successfully": "Conector eliminado correctamente",
"connector_duplicated_successfully": "Conector duplicado correctamente",
@@ -3660,12 +3596,9 @@
"csv_import_duplicate_warning": "Importar datos dos veces creará registros duplicados.",
"csv_inconsistent_columns": "La fila {row} tiene columnas inconsistentes. Todas las filas deben tener los mismos encabezados.",
"csv_max_records": "Máximo de {max} registros permitidos.",
"custom_source_type": "Tipo de fuente personalizado",
"custom_source_type_placeholder": "Ingrese el tipo de fuente personalizado",
"default_connector_name_csv": "Importación CSV",
"default_connector_name_formbricks": "Conexión de encuesta de Formbricks",
"discard_feedback_record_changes_description": "Sus cambios se perderán si cierra este cajón.",
"discard_feedback_record_changes_title": "¿Descartar los cambios no guardados?",
"deselect_all": "Deseleccionar todo",
"drop_a_field_here": "Suelta un campo aquí",
"drop_field_or": "Suelta el campo o",
"edit_csv_mapping": "Editar mapeo de CSV",
@@ -3675,64 +3608,47 @@
"enum": "enum",
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
"feedback_date": "Fecha actual",
"feedback_record_created_successfully": "Registro de comentarios creado correctamente",
"feedback_record_details": "Detalles del registro de comentarios",
"feedback_record_details_description": "Revise y actualice los campos del registro de comentarios.",
"feedback_record_directory": "Directorio de Registros de Comentarios",
"feedback_record_fields": "Campos de registro de comentarios",
"feedback_record_mcp": "MCP de registros de feedback",
"feedback_record_updated_successfully": "Registro de comentarios actualizado correctamente",
"feedback_record_value_required": "Se requiere un valor para el tipo de campo seleccionado",
"feedback_records": "Registros de comentarios",
"feedback_records_refreshed": "Registros de comentarios actualizados",
"feedback_sources": "Fuentes de feedback",
"feedback_sources_directory_access_multiple": "Los nuevos registros de estas fuentes se almacenarán en: {directoryNames}",
"feedback_sources_directory_access_single": "Los nuevos registros de esta fuente se almacenarán en: {directoryNames}",
"feedback_sources_settings_description": "Conecta y gestiona todas las fuentes de feedback para este espacio de trabajo.",
"field_group_id": "ID de grupo de campos",
"field_group_label": "Etiqueta de grupo de campos",
"field_id": "ID de campo",
"field_label": "Etiqueta de campo",
"field_type": "Tipo de campo",
"formbricks_surveys": "Formbricks Surveys",
"frd_cannot_be_changed": "El directorio de comentarios no se puede cambiar después de su creación.",
"go_to_feedback_record_directories": "Ir a la configuración de directorios",
"historical_import_complete": "Importación completada: {successes} correctas, {failures} fallidas, {skipped} omitidas (sin datos)",
"import_csv_data": "Importar comentarios",
"import_feedback": "Importar comentarios",
"import_historical_responses": "Importar respuestas históricas",
"import_historical_responses_description": "Importa las respuestas existentes de esta encuesta ahora.",
"import_rows": "Importar {count} filas",
"import_via_source_name": "Importar mediante \"{sourceName}\"",
"importing_data": "Importando datos...",
"importing_historical_data": "Importando datos históricos...",
"invalid_enum_values": "Valores no válidos en la columna asignada a {field}",
"invalid_values_found": "Encontrados: {values} (filas: {rows}) {extra}",
"load_sample_csv": "Cargar CSV de muestra",
"manage_directories": "Gestionar directorios",
"manage_feedback_sources": "Administrar fuentes de comentarios",
"metadata": "Metadatos",
"metadata_key": "Clave de metadatos",
"metadata_read_only_entries": "Valores de metadatos de solo lectura (no cadenas)",
"metadata_value": "Valor de metadatos",
"missing_feedback_source_title": "¿Falta alguna fuente de feedback?",
"n_supported_questions": "{count} preguntas compatibles",
"no_feedback_record_directory_available": "No hay ningún directorio de registros de comentarios asignado a este espacio de trabajo. Crea o asigna uno primero.",
"no_feedback_records": "Aún no hay registros de comentarios. Los registros aparecerán aquí una vez que tus conectores empiecen a enviar datos.",
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
"no_surveys_found": "No se encontraron encuestas en este entorno",
"optional": "Opcional",
"or_drag_and_drop": "o arrastra y suelta",
"question_selected": "<strong>{count}</strong> pregunta seleccionada. Cada respuesta a esta pregunta creará un registro de feedback nuevo.",
"question_type_not_supported": "Este tipo de pregunta no es compatible",
"questions_selected": "<strong>{count}</strong> preguntas seleccionadas. Cada respuesta a estas preguntas creará un registro de feedback nuevo.",
"records_will_go_to": "Los registros se enviarán a",
"refresh_feedback_records": "Actualizar los registros de comentarios",
"refreshing_feedback_records": "Actualizando registros de comentarios...",
"request_feedback_source": "Solicitar integración de fuente",
"required": "Obligatorio",
"save_changes": "Guardar cambios",
"select_a_survey_to_see_questions": "Selecciona una encuesta para ver sus preguntas",
"select_a_value": "Selecciona un valor...",
"select_all": "Seleccionar todo",
"select_feedback_record_directory": "Selecciona un directorio",
"select_feedback_record_source_type": "Seleccionar tipo de fuente",
"select_questions": "Seleccionar preguntas",
"select_source_type_description": "Selecciona el tipo de fuente de feedback que quieres conectar.",
"select_source_type_prompt": "Selecciona el tipo de fuente de feedback que quieres conectar:",
"select_survey": "Seleccionar encuesta",
"select_survey_and_questions": "Seleccionar encuesta y preguntas",
"select_survey_questions_description": "Elige qué preguntas de la encuesta deben crear FeedbackRecords.",
@@ -3742,31 +3658,27 @@
"showing_rows": "Mostrando 3 de {count} filas",
"source": "origen",
"source_connect_csv_description": "Importar feedback desde archivos CSV",
"source_connect_feedback_record_mcp_description": "Envía registros de feedback a través de la integración MCP.",
"source_connect_formbricks_description": "Conectar feedback de tus encuestas de Formbricks",
"source_fields": "Campos de origen",
"source_id": "ID de fuente",
"source_name": "Nombre de origen",
"source_type": "Tipo de fuente",
"source_type_cannot_be_changed": "El tipo de origen no se puede cambiar",
"sources": "Orígenes",
"status_active": "En progreso",
"status_completed": "Completado",
"status_draft": "Borrador",
"status_error": "Error",
"status_live_sync": "Sincronización en vivo",
"status_paused": "Pausado",
"status_ready": "Listo",
"submission_id": "ID de envío",
"survey_has_no_questions": "Esta encuesta no tiene preguntas",
"topics_and_subtopics": "Temas y subtemas",
"survey_import_line": "{surveyName}: {responseCount} respuestas × {questionCount} preguntas = {total} registros de feedback",
"total_feedback_records": "Total: {checked} de {total} registros de feedback seleccionados en {surveyCount} encuestas",
"unify_feedback": "Unificar feedback",
"update_mapping_description": "Actualiza la configuración de mapeo para esta fuente.",
"updated_at": "Actualizado el",
"upload_csv_data_description": "Sube un archivo CSV para importar datos de comentarios.",
"upload_csv_file": "Subir archivo CSV",
"user_identifier": "Usuario",
"value": "Valor",
"value_boolean": "Valor (booleano)",
"value_date": "Valor (Fecha)",
"value_number": "Valor (Número)",
"value_text": "Valor (Texto)"
"value": "Valor"
},
"xm-templates": {
"ces": "CES",

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