Compare commits

..

60 Commits

Author SHA1 Message Date
Johannes
5854bd635f add translations 2026-03-02 15:46:37 -03:00
Johannes
25ebb6411c feat: enhance AI settings management in organization
Updated organization AI settings to include separate toggles for smart tools and data analysis features. Refactored related components and tests to accommodate the new settings structure, ensuring a more granular control over AI functionalities. Adjusted localization strings for clarity on AI features.
2026-03-02 15:43:26 -03:00
Dhruwang
421a732875 feat: refactor organization update actions for name and AI settings
Updated the organization update actions to separate the handling of organization name and AI settings. Introduced `updateOrganizationNameAction` and `updateOrganizationAISettingsAction` for more specific updates. Adjusted related components to utilize the new actions accordingly.
2026-03-02 14:13:02 +05:30
Dhruwang
03ec8603bb feat: add Formbricks AI toggle to organization settings
Made-with: Cursor
2026-03-02 14:07:35 +05:30
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
7eb94f0bd5 fix: theme styling preview, option border color, and enable custom styling behavior (#7387)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-02 06:17:52 +00:00
Johannes
6dd2e707fe feat: display Formbricks version alongside organization ID in settings (#7363) 2026-03-02 05:54:23 +00:00
Matti Nannt
58d5de7d45 fix: resolve Dependabot Next.js deserialization alert (#7393) 2026-02-27 22:18:38 +01:00
Dhruwang Jariwala
7c3fa8b5ea fix: restore bullet points in survey preview and public survey (#7356) (#7360)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-27 18:24:15 +00:00
Harsh Bhat
2601169877 docs: add advanced CSS variable updates (#7389)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-02-27 17:19:22 +00:00
bharath kumar
aecf85815a fix(js-core): use closest() fallback for nested click target matching (#7327)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-27 06:24:58 +00:00
Dhruwang Jariwala
f8fa29d56e feat: charts ui (#7332)
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-26 12:21:48 +00:00
Bhagya Amarasinghe
c6ebaea989 fix: set success_action_status on S3 presigned POST to fix CORS on Ceph-based providers (#7362) 2026-02-26 10:26:49 +00:00
Bhagya Amarasinghe
68c1422733 fix: copy database package.json to Docker runner stage (#7371) 2026-02-26 10:25:28 +00:00
Dhruwang Jariwala
6942502baf fix: slack missing redirect uri (#7372) 2026-02-26 10:01:25 +00:00
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
Theodór Tómas
a4bd217761 chore: update to zod 3.25.76 (#7366) 2026-02-26 05:17:20 +00:00
Bhagya Amarasinghe
fee770358c perf(contacts): build segment WHERE clauses sequentially to prevent pool saturation (#7354)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-25 15:25:32 +00:00
Dhruwang Jariwala
44f8f80cac docs: clarify startAt is block-based, not question-based (#1404) (#7352)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 13:19:30 +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
Chowdhury Tafsir Ahmed Siddiki
858a7f7aa9 fix: replace toSorted in breadcrumb switchers for compatibility (#7325)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-25 06:29:31 +00:00
Gulshan
ac40b90e81 fix: made "Filter" string translatable (#7301)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-25 06:28:51 +00:00
Balázs Úr
aa21b4e442 fix: made Contact's page titles and table headers translatable (#7313)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-24 14:07:05 +00:00
Dhruwang Jariwala
fa72296de5 fix: error state for multi select question (#7335) 2026-02-24 13:34:48 +00:00
Johannes
3776b31794 feat: add impressions tab and display data retrieval for surveys (#7266)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-24 11:00:58 +00:00
Bhagya Amarasinghe
5c7ea33fb0 feat: add pod disruption budget for helm chart (#7339) 2026-02-24 10:43:16 +00:00
Balázs Úr
33f60ce2be fix: button label on create attribute dialog (#7331)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-24 08:30:20 +00:00
Bhagya Amarasinghe
c0386cea5a perf(contacts): batch segment evaluation queries into single transaction (#7333)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 08:26:46 +00:00
Theodór Tómas
d670d5de31 feat: (dashboards) listing page (#7330) 2026-02-23 20:26:03 +07:00
Anshuman Pandey
7cea53130c chore: adds webhook signing to test event (#7320) 2026-02-23 12:36:50 +00:00
Dhruwang Jariwala
0636989d67 fix: update test configuration to exclude .next directory from testing (#7334) 2026-02-23 11:33:17 +01: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
Anshuman Pandey
219883266c fix: add bool support (#7323) 2026-02-20 15:30:40 +00:00
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
55fc2b2bc8 chore: removing i18n from pre-commit hook (#7318) 2026-02-20 10:48:44 +00:00
neila
6e4ef9a099 fix: make pretty URL paths accessible from public domain (#7264)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-20 09:55:40 +00:00
Chowdhury Tafsir Ahmed Siddiki
ebf7d1e3a1 fix: prevent crash in NotificationSwitch via optional chaining (#7268)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-20 09:55:06 +00:00
Dhruwang Jariwala
998162bc48 fix: Google Sheets integration — token expiry & permission error handling (#7282) (#7285) 2026-02-20 08:56:24 +00:00
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
Anshuman Pandey
4fadc54b4e fix: fixes storage resolution issues (#7310) 2026-02-19 14:03:19 +00:00
Dhruwang Jariwala
f4ac9a8292 fix: always validate only responseData fields in client/management APIs (#7292) (#7296)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 08:56:42 +00:00
Anshuman Pandey
7c8a7606b7 fix: fixes the no segment in draft surveys bug (#7290) 2026-02-19 08:16:18 +00:00
Anshuman Pandey
225217330b fix: adds dataType filter in bc code (#7294) 2026-02-19 07:47:58 +00:00
Dhruwang Jariwala
589c04a530 fix: allow CTA elements to proceed when marked required (#1415) (#7293)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 06:56:03 +00:00
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
Anshuman Pandey
aa538a3a51 fix: better query in the backwards compatible code (#7288) 2026-02-18 13:00:19 +00:00
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
317 changed files with 15395 additions and 10267 deletions

View File

@@ -38,15 +38,6 @@ LOG_LEVEL=info
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
################
# MAIL SETUP #
################
@@ -238,5 +229,24 @@ REDIS_URL=redis://localhost:6379
# AUDIT_LOG_GET_USER_IP=0
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
# CUBEJS_API_SECRET=
# URL where the Cube.js instance is running
# CUBEJS_API_URL=http://localhost:4000
# 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
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=hub
# CUBEJS_DB_USER=formbricks
# CUBEJS_DB_PASS=formbricks_dev
#
# Alternative (when not on same Docker network): host.docker.internal and port 5433
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here

View File

@@ -6,19 +6,9 @@ permissions:
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
push:
branches:
- main
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
jobs:
validate-translations:
@@ -33,30 +23,38 @@ jobs:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for relevant changes
id: changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
filters: |
translations:
- 'apps/web/**/*.ts'
- 'apps/web/**/*.tsx'
- 'apps/web/locales/**/*.json'
- 'packages/surveys/src/**/*.{ts,tsx}'
- 'packages/surveys/locales/**/*.json'
- 'packages/email/**/*.{ts,tsx}'
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
- name: Install pnpm
if: steps.changes.outputs.translations == 'true'
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys
run: |
echo ""
echo "🔍 Validating translation keys..."
echo ""
pnpm run scan-translations
if: steps.changes.outputs.translations == 'true'
run: pnpm run scan-translations
- name: Summary
if: success()
run: |
echo ""
echo "✅ Translation validation completed successfully!"
echo ""
- name: Skip (no translation-related changes)
if: steps.changes.outputs.translations != 'true'
run: echo "No translation-related files changed — skipping validation."

View File

@@ -1,40 +1 @@
# Load environment variables from .env files
if [ -f .env ]; then
set -a
. .env
set +a
fi
pnpm lint-staged
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
if [ -n "$LINGODOTDEV_API_KEY" ]; then
echo ""
echo "🌍 Running Lingo.dev translation workflow..."
echo ""
# Run translation generation and validation
if pnpm run i18n; then
echo ""
echo "✅ Translation validation passed"
echo ""
# Add updated locale files to git
git add apps/web/locales/*.json
else
echo ""
echo "❌ Translation validation failed!"
echo ""
echo "Please fix the translation issues above before committing:"
echo " • Add missing translation keys to your locale files"
echo " • Remove unused translation keys"
echo ""
echo "Or run 'pnpm i18n' to see the detailed report"
echo ""
exit 1
fi
else
echo ""
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
echo " (This is expected for community contributors)"
echo ""
fi
pnpm lint-staged

View File

@@ -32,6 +32,7 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
We are using SonarQube to identify code smells and security hotspots.
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
## Architecture & Patterns

View File

@@ -101,6 +101,9 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
# Create packages/database directory structure with proper ownership for runtime migrations
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma

View File

@@ -228,7 +228,7 @@ export const ProjectSettings = ({
</FormProvider>
</div>
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
<div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow">
{logoUrl && (
<Image
src={logoUrl}
@@ -239,18 +239,16 @@ export const ProjectSettings = ({
/>
)}
<p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}
autoFocus={false}
/>
</div>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}
autoFocus={false}
/>
</div>
<CreateTeamModal
open={createTeamModalOpen}

View File

@@ -0,0 +1,8 @@
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
const ChartsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
const { environmentId } = await props.params;
return <ChartsListPage environmentId={environmentId} />;
};
export default ChartsPage;

View File

@@ -0,0 +1,11 @@
const DashboardDetailPage = async (props: Readonly<{ params: Promise<{ dashboardId: string }> }>) => {
const { dashboardId } = await props.params;
return (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
Dashboard detail for {dashboardId} will appear here.
</div>
);
};
export default DashboardDetailPage;

View File

@@ -0,0 +1,8 @@
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
const DashboardsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
const { environmentId } = await props.params;
return <DashboardsListPage environmentId={environmentId} />;
};
export default DashboardsPage;

View File

@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
const AnalysisPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
const { environmentId } = await props.params;
return redirect(`/environments/${environmentId}/analysis/dashboards`);
};
export default AnalysisPage;

View File

@@ -2,6 +2,7 @@
import {
ArrowUpRightIcon,
ChartBar,
ChevronRightIcon,
Cog,
LogOutIcon,
@@ -9,7 +10,6 @@ import {
PanelLeftCloseIcon,
PanelLeftOpenIcon,
RocketIcon,
ShapesIcon,
UserCircleIcon,
UserIcon,
} from "lucide-react";
@@ -100,7 +100,7 @@ export const MainNavigation = ({
const mainNavigation = useMemo(
() => [
{
name: t("common.ask"),
name: t("common.surveys"),
href: `/environments/${environment.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
@@ -108,7 +108,7 @@ export const MainNavigation = ({
},
{
href: `/environments/${environment.id}/contacts`,
name: t("common.distribute"),
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
@@ -116,13 +116,14 @@ export const MainNavigation = ({
pathname?.includes("/attributes"),
},
{
name: t("common.unify"),
href: `/environments/${environment.id}/workspace/unify`,
icon: ShapesIcon,
isActive: pathname?.includes("/unify") && !pathname?.includes("/analyze"),
name: t("common.analysis"),
href: `/environments/${environment.id}/analysis`,
icon: ChartBar,
isActive: pathname?.includes("/analysis"),
isHidden: false,
},
{
name: t("common.configure"),
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/project"),

View File

@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort organizations by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
// Handle server errors or validation errors

View File

@@ -82,7 +82,7 @@ export const ProjectBreadcrumb = ({
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
// Handle server errors or validation errors
@@ -133,11 +133,6 @@ export const ProjectBreadcrumb = ({
label: t("common.tags"),
href: `/environments/${currentEnvironmentId}/workspace/tags`,
},
{
id: "unify",
label: t("common.unify"),
href: `/environments/${currentEnvironmentId}/workspace/unify`,
},
];
if (!currentProject) {

View File

@@ -11,12 +11,6 @@ const EnvLayout = async (props: {
children: React.ReactNode;
}) => {
const params = await props.params;
const { environmentId } = params;
if (environmentId === "undefined") {
return redirect("/");
}
const { children } = props;
// Check session first (required for userId)

View File

@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
const isChecked =
notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => {
setIsLoading(true);
@@ -49,8 +49,11 @@ export const NotificationSwitch = ({
];
}
} else {
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
updatedNotificationSettings[notificationType] = {
...updatedNotificationSettings[notificationType],
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
};
}
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
@@ -78,7 +81,7 @@ export const NotificationSwitch = ({
) {
switch (notificationType) {
case "alert":
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange();
toast.success(
t(

View File

@@ -27,7 +27,7 @@ export const updateOrganizationNameAction = authenticatedActionClient
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -51,6 +51,49 @@ export const updateOrganizationNameAction = authenticatedActionClient
)
);
const ZUpdateOrganizationAISettingsAction = z.object({
organizationId: ZId,
data: ZOrganizationUpdateInput.pick({ isAISmartToolsEnabled: true, isAIDataAnalysisEnabled: true }),
});
export const updateOrganizationAISettingsAction = authenticatedActionClient
.schema(ZUpdateOrganizationAISettingsAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
}),
data: parsedInput.data,
roles: ["owner", "manager"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZDeleteOrganizationAction = z.object({
organizationId: ZId,
});

View File

@@ -0,0 +1,101 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
interface AISettingsToggleProps {
organization: TOrganization;
membershipRole?: TOrganizationRole;
}
export const AISettingsToggle = ({ organization, membershipRole }: Readonly<AISettingsToggleProps>) => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const router = useRouter();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const canEdit = isOwner || isManager;
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
setIsLoading(true);
try {
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data: { [field]: checked },
});
if (response?.data) {
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(response);
toast.error(errorMessage);
}
} catch {
toast.error(t("common.something_went_wrong"));
} finally {
setIsLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex items-start space-x-2">
<Switch
id="ai-smart-tools-toggle"
className="mt-0.5"
checked={organization.isAISmartToolsEnabled}
disabled={isLoading || !canEdit}
onCheckedChange={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
/>
<div>
<Label htmlFor="ai-smart-tools-toggle">
{t("environments.settings.general.ai_smart_tools_enabled")}
</Label>
<p className="text-xs text-slate-500">
{t("environments.settings.general.ai_smart_tools_enabled_description")}
</p>
</div>
</div>
<div className="flex items-start space-x-2">
<Switch
id="ai-data-analysis-toggle"
className="mt-0.5"
checked={organization.isAIDataAnalysisEnabled}
disabled={isLoading || !canEdit}
onCheckedChange={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
/>
<div>
<Label htmlFor="ai-data-analysis-toggle">
{t("environments.settings.general.ai_data_analysis_enabled")}
</Label>
<p className="text-xs text-slate-500">
{t("environments.settings.general.ai_data_analysis_enabled_description")}
</p>
</div>
</div>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</div>
);
};

View File

@@ -9,7 +9,9 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard";
import { AISettingsToggle } from "./components/AISettingsToggle";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
@@ -59,6 +61,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
membershipRole={currentUserMembership?.role}
/>
</SettingsCard>
<SettingsCard
title={t("environments.settings.general.ai_enabled")}
description={t("environments.settings.general.ai_enabled_description")}>
<AISettingsToggle organization={organization} membershipRole={currentUserMembership?.role} />
</SettingsCard>
<EmailCustomizationSettings
organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission}
@@ -81,7 +88,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard>
)}
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<div className="space-y-2">
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
</div>
</PageContentWrapper>
);
};

View File

@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -106,3 +107,31 @@ export const getResponseCountAction = authenticatedActionClient
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
});
const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId,
limit: z.number().int().min(1).max(100),
offset: z.number().int().nonnegative(),
});
export const getDisplaysWithContactAction = authenticatedActionClient
.schema(ZGetDisplaysWithContactAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
return getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset);
});

View File

@@ -0,0 +1,125 @@
"use client";
import { AlertCircleIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Button } from "@/modules/ui/components/button";
interface SummaryImpressionsProps {
displays: TDisplayWithContact[];
isLoading: boolean;
hasMore: boolean;
displaysError: string | null;
environmentId: string;
locale: TUserLocale;
onLoadMore: () => void;
onRetry: () => void;
}
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
if (!display.contact) return "";
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
};
export const SummaryImpressions = ({
displays,
isLoading,
hasMore,
displaysError,
environmentId,
locale,
onLoadMore,
onRetry,
}: SummaryImpressionsProps) => {
const { t } = useTranslation();
const renderContent = () => {
if (displaysError) {
return (
<div className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex items-center gap-2 text-red-600">
<AlertCircleIcon className="h-5 w-5" />
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
</div>
<p className="text-sm text-slate-500">{displaysError}</p>
<Button onClick={onRetry} variant="secondary" size="sm">
{t("common.try_again")}
</Button>
</div>
</div>
);
}
if (displays.length === 0) {
return (
<div className="p-8 text-center text-sm text-slate-500">
{t("environments.surveys.summary.no_identified_impressions")}
</div>
);
}
return (
<>
<div className="grid min-h-10 grid-cols-4 items-center border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-2 px-4 md:px-6">{t("common.user")}</div>
<div className="col-span-2 px-4 md:px-6">{t("environments.contacts.survey_viewed_at")}</div>
</div>
<div className="max-h-[62vh] overflow-y-auto">
{displays.map((display) => (
<div
key={display.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-xs text-slate-800 last:border-transparent md:text-sm">
<div className="col-span-2 pl-4 md:pl-6">
{display.contact ? (
<Link
className="ph-no-capture break-all text-slate-600 hover:underline"
href={`/environments/${environmentId}/contacts/${display.contact.id}`}>
{getDisplayContactIdentifier(display)}
</Link>
) : (
<span className="break-all text-slate-600">{t("common.anonymous")}</span>
)}
</div>
<div className="col-span-2 px-4 text-slate-500 md:px-6">
{timeSince(display.createdAt.toString(), locale)}
</div>
</div>
))}
</div>
{hasMore && (
<div className="flex justify-center border-t border-slate-100 py-4">
<Button onClick={onLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</>
);
};
if (isLoading) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
<div className="flex items-center justify-center">
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
);
}
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<InfoIcon className="h-4 w-4 shrink-0" />
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
</div>
{renderContent()}
</div>
);
};

View File

@@ -10,8 +10,8 @@ interface SummaryMetadataProps {
surveySummary: TSurveySummary["meta"];
quotasCount: number;
isLoading: boolean;
tab: "dropOffs" | "quotas" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
tab: "dropOffs" | "quotas" | "impressions" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>;
isQuotasAllowed: boolean;
}
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
const { t } = useTranslation();
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
const handleTabChange = (val: "dropOffs" | "quotas") => {
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
const change = tab === val ? undefined : val;
setTab(change);
};
@@ -65,12 +65,16 @@ export const SummaryMetadata = ({
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
)}>
<StatCard
<InteractiveCard
key="impressions"
tab="impressions"
label={t("environments.surveys.summary.impressions")}
percentage={null}
value={displayCount === 0 ? <span>-</span> : displayCount}
tooltipText={t("environments.surveys.summary.impressions_tooltip")}
isLoading={isLoading}
onClick={() => handleTabChange("impressions")}
isActive={tab === "impressions"}
/>
<StatCard
label={t("environments.surveys.summary.starts")}

View File

@@ -1,21 +1,31 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import {
getDisplaysWithContactAction,
getSurveySummaryAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { SummaryImpressions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryImpressions";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
import { SummaryList } from "./SummaryList";
import { SummaryMetadata } from "./SummaryMetadata";
const DISPLAYS_PER_PAGE = 15;
const defaultSurveySummary: TSurveySummary = {
meta: {
completedPercentage: 0,
@@ -51,17 +61,76 @@ export const SummaryPage = ({
initialSurveySummary,
isQuotasAllowed,
}: SummaryPageProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary
);
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
const [hasMoreDisplays, setHasMoreDisplays] = useState(true);
const [displaysError, setDisplaysError] = useState<string | null>(null);
const displaysFetchedRef = useRef(false);
const fetchDisplays = useCallback(
async (offset: number) => {
const response = await getDisplaysWithContactAction({
surveyId,
limit: DISPLAYS_PER_PAGE,
offset,
});
if (!response?.data) {
const errorMessage = getFormattedErrorMessage(response);
throw new Error(errorMessage);
}
return response?.data ?? [];
},
[surveyId]
);
const loadInitialDisplays = useCallback(async () => {
setIsDisplaysLoading(true);
setDisplaysError(null);
try {
const data = await fetchDisplays(0);
setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
toast.error(error);
setDisplays([]);
setHasMoreDisplays(false);
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays, t]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
const data = await fetchDisplays(displays.length);
setDisplays((prev) => [...prev, ...data]);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
}
}, [fetchDisplays, displays.length, t]);
useEffect(() => {
if (tab === "impressions" && !displaysFetchedRef.current) {
displaysFetchedRef.current = true;
loadInitialDisplays();
}
}, [tab, loadInitialDisplays]);
// Only fetch data when filters change or when there's no initial data
useEffect(() => {
// If we have initial data and no filters are applied, don't fetch
@@ -121,6 +190,18 @@ export const SummaryPage = ({
setTab={setTab}
isQuotasAllowed={isQuotasAllowed}
/>
{tab === "impressions" && (
<SummaryImpressions
displays={displays}
isLoading={isDisplaysLoading}
hasMore={hasMoreDisplays}
displaysError={displaysError}
environmentId={environment.id}
locale={locale}
onLoadMore={handleLoadMoreDisplays}
onRetry={loadInitialDisplays}
/>
)}
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
<div className="flex gap-1.5">

View File

@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
interface InteractiveCardProps {
tab: "dropOffs" | "quotas";
tab: "dropOffs" | "quotas" | "impressions";
label: string;
percentage: number;
percentage: number | null;
value: React.ReactNode;
tooltipText: string;
isLoading: boolean;

View File

@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
},
{
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
},
]}
/>

View File

@@ -241,7 +241,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<PopoverTriggerButton isOpen={isOpen}>
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
{t("common.filter")} <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
</PopoverTriggerButton>
</PopoverTrigger>
<PopoverContent
@@ -329,7 +329,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
</div>
{i !== filterValue.filter.length - 1 && (
<div className="my-4 flex items-center">
<p className="mr-4 font-semibold text-slate-800">and</p>
<p className="mr-4 font-semibold text-slate-800">{t("common.and")}</p>
<hr className="w-full text-slate-600" />
</div>
)}

View File

@@ -1,12 +1,49 @@
"use server";
import { z } from "zod";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
import { ZId } from "@formbricks/types/common";
import {
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
} from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/googleSheet/service";
import { getIntegrationByType } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
const ZValidateGoogleSheetsConnectionAction = z.object({
environmentId: ZId,
});
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.schema(ZValidateGoogleSheetsConnectionAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
if (!integration) {
return { data: false };
}
await validateGoogleSheetsConnection(integration as TIntegrationGoogleSheets);
return { data: true };
});
const ZGetSpreadsheetNameByIdAction = z.object({
googleSheetIntegration: ZIntegrationGoogleSheets,
environmentId: z.string(),

View File

@@ -20,6 +20,10 @@ import {
isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -118,6 +122,17 @@ export const AddIntegrationModal = ({
resetForm();
}, [selectedIntegration, surveys]);
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
const errorMessage = getFormattedErrorMessage(response);
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
toast.error(t("environments.integrations.google_sheets.token_expired_error"));
} else if (errorMessage === GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION) {
toast.error(t("environments.integrations.google_sheets.spreadsheet_permission_error"));
} else {
toast.error(errorMessage);
}
};
const linkSheet = async () => {
try {
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
@@ -129,6 +144,7 @@ export const AddIntegrationModal = ({
if (selectedElements.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
}
setIsLinkingSheet(true);
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
googleSheetIntegration,
@@ -137,13 +153,11 @@ export const AddIntegrationModal = ({
});
if (!spreadsheetNameResponse?.data) {
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
throw new Error(errorMessage);
showErrorMessageToast(spreadsheetNameResponse);
return;
}
const spreadsheetName = spreadsheetNameResponse.data;
setIsLinkingSheet(true);
integrationData.spreadsheetId = spreadsheetId;
integrationData.spreadsheetName = spreadsheetName;
integrationData.surveyId = selectedSurvey.id;

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
@@ -8,9 +8,11 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { AddIntegrationModal } from "./AddIntegrationModal";
@@ -35,10 +37,23 @@ export const GoogleSheetWrapper = ({
googleSheetIntegration ? googleSheetIntegration.config?.key : false
);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
>(null);
const validateConnection = useCallback(async () => {
if (!isConnected || !googleSheetIntegration) return;
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id });
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
setShowReconnectButton(true);
}
}, [environment.id, isConnected, googleSheetIntegration]);
useEffect(() => {
validateConnection();
}, [validateConnection]);
const handleGoogleAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => {
if (url) {
@@ -64,6 +79,8 @@ export const GoogleSheetWrapper = ({
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
showReconnectButton={showReconnectButton}
handleGoogleAuthorization={handleGoogleAuthorization}
locale={locale}
/>
</>

View File

@@ -1,6 +1,6 @@
"use client";
import { Trash2Icon } from "lucide-react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,15 +12,19 @@ import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ManageIntegrationProps {
googleSheetIntegration: TIntegrationGoogleSheets;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
showReconnectButton: boolean;
handleGoogleAuthorization: () => void;
locale: TUserLocale;
}
@@ -29,6 +33,8 @@ export const ManageIntegration = ({
setOpenAddIntegrationModal,
setIsConnected,
setSelectedIntegration,
showReconnectButton,
handleGoogleAuthorization,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslation();
@@ -68,7 +74,17 @@ export const ManageIntegration = ({
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>
{t("environments.integrations.google_sheets.reconnect_button_description")}
</AlertDescription>
<AlertButton onClick={handleGoogleAuthorization}>
{t("environments.integrations.google_sheets.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
@@ -77,6 +93,19 @@ export const ManageIntegration = ({
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleGoogleAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.google_sheets.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("environments.integrations.google_sheets.reconnect_button_tooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setSelectedIntegration(null);

View File

@@ -1,25 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface UnifyConfigNavigationProps {
environmentId: string;
activeId?: string;
loading?: boolean;
}
export const UnifyConfigNavigation = ({
environmentId,
activeId: activeIdProp,
loading,
}: UnifyConfigNavigationProps) => {
const { t } = useTranslation();
const baseHref = `/environments/${environmentId}/workspace/unify`;
const activeId = activeIdProp ?? "sources";
const navigation = [{ id: "sources", label: t("environments.unify.sources"), href: `${baseHref}/sources` }];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
};

View File

@@ -1,6 +0,0 @@
import { redirect } from "next/navigation";
export default async function UnifyPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
redirect(`/environments/${params.environmentId}/workspace/unify/sources`);
}

View File

@@ -1,38 +0,0 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { getSurveys } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { transformToUnifySurvey } from "./lib";
import { TUnifySurvey } from "./types";
const ZGetSurveysForUnifyAction = z.object({
environmentId: ZId,
});
export const getSurveysForUnifyAction = authenticatedActionClient
.schema(ZGetSurveysForUnifyAction)
.action(async ({ ctx, parsedInput }): Promise<TUnifySurvey[]> => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "member"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
const surveys = await getSurveys(parsedInput.environmentId);
return surveys.map((survey) => transformToUnifySurvey(survey));
});

View File

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

View File

@@ -1,56 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { TConnectorType } from "@formbricks/types/connector";
import { Badge } from "@/modules/ui/components/badge";
import { getConnectorOptions } from "../utils";
interface ConnectorTypeSelectorProps {
selectedType: TConnectorType | null;
onSelectType: (type: TConnectorType) => void;
}
export function ConnectorTypeSelector({ selectedType, onSelectType }: ConnectorTypeSelectorProps) {
const { t } = useTranslation();
const connectorOptions = getConnectorOptions(t);
return (
<div className="space-y-3">
<p className="text-sm text-slate-600">{t("environments.unify.select_source_type_prompt")}</p>
<div className="space-y-2">
{connectorOptions.map((option) => (
<button
key={option.id}
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id as TConnectorType)}
className={`flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors ${
selectedType === option.id
? "border-brand-dark bg-slate-50"
: option.disabled
? "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60"
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
</div>
<div
className={`ml-4 h-5 w-5 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-2 w-2 rounded-full bg-white" />
</div>
)}
</div>
</button>
))}
</div>
</div>
);
}

View File

@@ -1,185 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TConnectorType, TConnectorWithMappings, THubTargetField } from "@formbricks/types/connector";
import {
createConnectorWithMappingsAction,
deleteConnectorAction,
duplicateConnectorAction,
updateConnectorWithMappingsAction,
} from "@/lib/connector/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
import { TFieldMapping, TUnifySurvey } from "../types";
import { ConnectorsTable } from "./connectors-table";
import { CreateConnectorModal } from "./create-connector-modal";
import { EditConnectorModal } from "./edit-connector-modal";
interface ConnectorsSectionProps {
environmentId: string;
initialConnectors: TConnectorWithMappings[];
initialSurveys: TUnifySurvey[];
}
export function ConnectorsSection({
environmentId,
initialConnectors,
initialSurveys,
}: ConnectorsSectionProps) {
const { t } = useTranslation();
const router = useRouter();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingConnector, setEditingConnector] = useState<TConnectorWithMappings | null>(null);
const handleCreateConnector = async (data: {
name: string;
type: TConnectorType;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}): Promise<string | undefined> => {
const result = await createConnectorWithMappingsAction({
environmentId,
connectorInput: {
name: data.name,
type: data.type,
},
formbricksMappings:
data.type === "formbricks" && data.surveyMappings?.length ? data.surveyMappings : undefined,
fieldMappings:
data.type !== "formbricks" && data.fieldMappings?.length
? data.fieldMappings.map((m) => ({
sourceFieldId: m.sourceFieldId || "",
targetFieldId: m.targetFieldId as THubTargetField,
staticValue: m.staticValue,
}))
: undefined,
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return undefined;
}
toast.success(t("environments.unify.connector_created_successfully"));
router.refresh();
return result.data.id;
};
const handleUpdateConnector = async (data: {
connectorId: string;
environmentId: string;
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => {
const result = await updateConnectorWithMappingsAction({
connectorId: data.connectorId,
environmentId,
connectorInput: {
name: data.name,
},
formbricksMappings: data.surveyMappings?.length ? data.surveyMappings : undefined,
fieldMappings: data.fieldMappings?.length
? data.fieldMappings.map((m) => ({
sourceFieldId: m.sourceFieldId || "",
targetFieldId: m.targetFieldId as THubTargetField,
staticValue: m.staticValue,
}))
: undefined,
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.unify.connector_updated_successfully"));
router.refresh();
};
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
const result = await deleteConnectorAction({ connectorId, environmentId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.unify.connector_deleted_successfully"));
router.refresh();
};
const handleDuplicateConnector = async (connector: TConnectorWithMappings): Promise<void> => {
const result = await duplicateConnectorAction({
connectorId: connector.id,
environmentId,
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.unify.connector_duplicated_successfully"));
router.refresh();
};
const handleToggleStatus = async (connector: TConnectorWithMappings): Promise<void> => {
const newStatus = connector.status === "active" ? "paused" : "active";
const result = await updateConnectorWithMappingsAction({
connectorId: connector.id,
environmentId,
connectorInput: { status: newStatus },
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.unify.connector_status_updated_successfully"));
router.refresh();
};
return (
<PageContentWrapper>
<PageHeader
pageTitle={t("environments.unify.unify_feedback")}
cta={
<CreateConnectorModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreateConnector={handleCreateConnector}
surveys={initialSurveys}
environmentId={environmentId}
/>
}>
<UnifyConfigNavigation environmentId={environmentId} />
</PageHeader>
<div className="space-y-6">
<ConnectorsTable
connectors={initialConnectors}
onConnectorClick={setEditingConnector}
onDuplicate={handleDuplicateConnector}
onToggleStatus={handleToggleStatus}
onDelete={handleDeleteConnector}
isLoading={false}
/>
</div>
<EditConnectorModal
connector={editingConnector}
open={editingConnector !== null}
onOpenChange={(open) => !open && setEditingConnector(null)}
onUpdateConnector={handleUpdateConnector}
surveys={initialSurveys}
/>
</PageContentWrapper>
);
}

View File

@@ -1,133 +0,0 @@
"use client";
import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TConnectorStatus, TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
import { Badge } from "@/modules/ui/components/badge";
import { ConnectorRowDropdown } from "./connector-row-dropdown";
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
{ amount: 60, unit: "seconds" },
{ amount: 60, unit: "minutes" },
{ amount: 24, unit: "hours" },
{ amount: 7, unit: "days" },
{ amount: 4.345, unit: "weeks" },
{ amount: 12, unit: "months" },
{ amount: Number.POSITIVE_INFINITY, unit: "years" },
];
function getRelativeTime(date: Date, locale: string) {
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
let duration = (date.getTime() - Date.now()) / 1000;
for (const division of RELATIVE_TIME_DIVISIONS) {
if (Math.abs(duration) < division.amount) {
return formatter.format(Math.round(duration), division.unit);
}
duration /= division.amount;
}
return formatter.format(Math.round(duration), "years");
}
interface ConnectorsTableDataRowProps {
connector: TConnectorWithMappings;
onEdit: () => void;
onDuplicate: () => Promise<void>;
onToggleStatus: () => Promise<void>;
onDelete: () => Promise<void>;
}
function getConnectorIcon(type: TConnectorType) {
switch (type) {
case "formbricks":
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
default:
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
}
}
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
active: "success",
paused: "warning",
error: "error",
};
export function ConnectorsTableDataRow({
connector,
onEdit,
onDuplicate,
onToggleStatus,
onDelete,
}: ConnectorsTableDataRowProps) {
const { t, i18n } = useTranslation();
const getStatusLabel = (s: TConnectorStatus) => {
switch (s) {
case "active":
return t("environments.unify.status_active");
case "paused":
return t("environments.unify.status_paused");
case "error":
return t("environments.unify.status_error");
}
};
const getConnectorTypeLabel = (connectorType: TConnectorType) => {
switch (connectorType) {
case "formbricks":
return t("environments.unify.formbricks_surveys");
case "csv":
return t("environments.unify.csv_import");
default:
return connectorType;
}
};
return (
<div
role="button"
tabIndex={0}
className="grid h-12 min-h-12 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
onClick={onEdit}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onEdit();
}
}}>
<div className="col-span-1 flex items-center gap-2 pl-4" title={getConnectorTypeLabel(connector.type)}>
{getConnectorIcon(connector.type)}
</div>
<div className="col-span-3 flex items-center">
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
</div>
<div className="col-span-1 hidden items-center justify-center sm:flex">
<Badge
text={getStatusLabel(connector.status)}
type={STATUS_BADGE_TYPE[connector.status]}
size="tiny"
/>
</div>
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{getRelativeTime(connector.createdAt, i18n.language)}
</div>
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{getRelativeTime(connector.updatedAt, i18n.language)}
</div>
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
<span className="truncate">{connector.creatorName ?? "—"}</span>
</div>
<div className="col-span-1 flex items-center justify-end pr-2">
<ConnectorRowDropdown
connector={connector}
onEdit={onEdit}
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,329 +0,0 @@
"use client";
import { useDraggable, useDroppable } from "@dnd-kit/core";
import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { cn } from "@/modules/ui/lib/utils";
import { TFieldMapping, TSourceField, TTargetField } from "../types";
interface DraggableSourceFieldProps {
field: TSourceField;
isMapped: boolean;
}
export function DraggableSourceField({ field, isMapped }: DraggableSourceFieldProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: field.id,
data: field,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`flex cursor-grab items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isDragging
? "border-brand-dark bg-slate-100 opacity-50"
: isMapped
? "border-green-300 bg-green-50 text-green-800"
: "border-slate-200 bg-white hover:border-slate-300"
}`}>
<GripVerticalIcon className="h-4 w-4 text-slate-400" />
<div className="flex-1 truncate">
<span className="font-medium">{field.name}</span>
<span className="ml-2 text-xs text-slate-500">({field.type})</span>
</div>
{field.sampleValue && (
<span className="max-w-24 truncate text-xs text-slate-400">{field.sampleValue}</span>
)}
</div>
);
}
interface DroppableTargetFieldProps {
field: TTargetField;
mappedSourceField: TSourceField | null;
mapping: TFieldMapping | null;
onRemoveMapping: () => void;
onStaticValueChange: (value: string) => void;
isOver?: boolean;
}
export const DroppableTargetField = ({
field,
mappedSourceField,
mapping,
onRemoveMapping,
onStaticValueChange,
isOver,
}: DroppableTargetFieldProps) => {
const { t } = useTranslation();
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
id: field.id,
data: field,
});
const [isEditingStatic, setIsEditingStatic] = useState(false);
const [customValue, setCustomValue] = useState("");
const isActive = isOver || isOverCurrent;
const hasMapping = mappedSourceField || mapping?.staticValue;
// Handle enum field type - support both column mapping and static dropdown
if (field.type === "enum" && field.enumValues) {
return (
<div
ref={setNodeRef}
className={cn(
`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isActive
? "border-brand-dark bg-slate-100"
: hasMapping
? "border-green-300 bg-green-50"
: "border-dashed border-slate-300 bg-slate-50"
}`
)}>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
<span className="text-xs text-slate-400">{t("environments.unify.enum")}</span>
</div>
{mappedSourceField && !mapping?.staticValue ? (
<div className="flex items-center gap-1">
<span className="text-xs text-green-700">&larr; {mappedSourceField.name}</span>
<button
type="button"
onClick={onRemoveMapping}
className="ml-1 rounded p-0.5 hover:bg-green-100">
<XIcon className="h-3 w-3 text-green-600" />
</button>
</div>
) : (
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
<SelectTrigger className="h-8 w-full bg-white">
<SelectValue placeholder={t("environments.unify.select_a_value")} />
</SelectTrigger>
<SelectContent>
{field.enumValues.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
);
}
// Handle string fields - allow drag & drop OR static value
if (field.type === "string") {
return (
<div
ref={setNodeRef}
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isActive
? "border-brand-dark bg-slate-100"
: hasMapping
? "border-green-300 bg-green-50"
: "border-dashed border-slate-300 bg-slate-50"
}`}>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
</div>
{/* Show mapped source field */}
{mappedSourceField && !mapping?.staticValue && (
<div className="flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<button
type="button"
onClick={onRemoveMapping}
className="ml-1 rounded p-0.5 hover:bg-green-100">
<XIcon className="h-3 w-3 text-green-600" />
</button>
</div>
)}
{/* Show static value */}
{mapping?.staticValue && !mappedSourceField && (
<div className="flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= &ldquo;{mapping.staticValue}&rdquo;
</span>
<button
type="button"
onClick={onRemoveMapping}
className="ml-1 rounded p-0.5 hover:bg-blue-100">
<XIcon className="h-3 w-3 text-blue-600" />
</button>
</div>
)}
{/* Show input for entering static value when editing */}
{isEditingStatic && !hasMapping && (
<div className="flex items-center gap-1">
<Input
type="text"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
placeholder={
field.exampleStaticValues
? `e.g., ${field.exampleStaticValues[0]}`
: t("environments.unify.enter_value")
}
className="h-7 text-xs"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && customValue.trim()) {
onStaticValueChange(customValue.trim());
setCustomValue("");
setIsEditingStatic(false);
}
if (e.key === "Escape") {
setCustomValue("");
setIsEditingStatic(false);
}
}}
/>
<button
type="button"
onClick={() => {
if (customValue.trim()) {
onStaticValueChange(customValue.trim());
setCustomValue("");
}
setIsEditingStatic(false);
}}
className="rounded p-1 text-slate-500 hover:bg-slate-200">
<ChevronDownIcon className="h-3 w-3" />
</button>
</div>
)}
{/* Show example values as quick select OR drop zone */}
{!hasMapping && !isEditingStatic && (
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">{t("environments.unify.drop_field_or")}</span>
<button
type="button"
onClick={() => setIsEditingStatic(true)}
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-slate-500 hover:bg-slate-200">
<PencilIcon className="h-3 w-3" />
{t("environments.unify.set_value")}
</button>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.slice(0, 3).map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{val}
</button>
))}
</>
)}
</div>
)}
</div>
</div>
);
}
// Helper to get display label for static values
const getStaticValueLabel = (value: string) => {
if (value === "$now") return t("environments.unify.feedback_date");
return value;
};
// Default behavior for other field types (timestamp, float64, boolean, jsonb, etc.)
const hasDefaultMapping = mappedSourceField || mapping?.staticValue;
return (
<div
ref={setNodeRef}
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isActive
? "border-brand-dark bg-slate-100"
: hasDefaultMapping
? "border-green-300 bg-green-50"
: "border-dashed border-slate-300 bg-slate-50"
}`}>
<div className="flex flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
<span className="text-xs text-slate-400">({field.type})</span>
</div>
{/* Show mapped source field */}
{mappedSourceField && !mapping?.staticValue && (
<div className="mt-1 flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-green-100">
<XIcon className="h-3 w-3 text-green-600" />
</button>
</div>
)}
{/* Show static value */}
{mapping?.staticValue && !mappedSourceField && (
<div className="mt-1 flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= {getStaticValueLabel(mapping.staticValue)}
</span>
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-blue-100">
<XIcon className="h-3 w-3 text-blue-600" />
</button>
</div>
)}
{/* Show drop zone with preset options */}
{!hasDefaultMapping && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">{t("environments.unify.drop_a_field_here")}</span>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{getStaticValueLabel(val)}
</button>
))}
</>
)}
</div>
)}
</div>
</div>
);
};

View File

@@ -1,148 +0,0 @@
"use client";
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TConnectorType } from "@formbricks/types/connector";
import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField } from "../types";
import { DraggableSourceField, DroppableTargetField } from "./mapping-field";
interface MappingUIProps {
sourceFields: TSourceField[];
mappings: TFieldMapping[];
onMappingsChange: (mappings: TFieldMapping[]) => void;
connectorType: TConnectorType;
}
export function MappingUI({ sourceFields, mappings, onMappingsChange, connectorType }: MappingUIProps) {
const { t } = useTranslation();
const [activeId, setActiveId] = useState<string | null>(null);
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over) return;
const sourceFieldId = active.id as string;
const targetFieldId = over.id as string;
const newMappings = mappings.filter(
(m) => m.sourceFieldId !== sourceFieldId && m.targetFieldId !== targetFieldId
);
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
};
const handleRemoveMapping = (targetFieldId: string) => {
onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId));
};
const handleStaticValueChange = (targetFieldId: string, staticValue: string) => {
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
onMappingsChange([...newMappings, { targetFieldId, staticValue }]);
};
const getSourceFieldById = (id: string) => sourceFields.find((f) => f.id === id);
const getMappingForTarget = (targetFieldId: string) => {
return mappings.find((m) => m.targetFieldId === targetFieldId) ?? null;
};
const getMappedSourceField = (targetFieldId: string) => {
const mapping = getMappingForTarget(targetFieldId);
return mapping?.sourceFieldId ? getSourceFieldById(mapping.sourceFieldId) : null;
};
const isSourceFieldMapped = (sourceFieldId: string) =>
mappings.some((m) => m.sourceFieldId === sourceFieldId);
const activeField = activeId ? getSourceFieldById(activeId) : null;
return (
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="grid grid-cols-2 gap-6">
{/* Source Fields Panel */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">
{connectorType === "csv"
? t("environments.unify.csv_columns")
: t("environments.unify.source_fields")}
</h4>
{sourceFields.length === 0 ? (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">
{connectorType === "csv"
? t("environments.unify.click_load_sample_csv")
: t("environments.unify.no_source_fields_loaded")}
</p>
</div>
) : (
<div className="space-y-2">
{sourceFields.map((field) => (
<DraggableSourceField key={field.id} field={field} isMapped={isSourceFieldMapped(field.id)} />
))}
</div>
)}
</div>
{/* Target Fields Panel */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">
{t("environments.unify.feedback_record_fields")}
</h4>
{/* Required Fields */}
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{t("environments.unify.required")}
</p>
{requiredFields.map((field) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
/>
))}
</div>
{/* Optional Fields */}
<div className="mt-4 space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{t("environments.unify.optional")}
</p>
{optionalFields.map((field) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
/>
))}
</div>
</div>
</div>
<DragOverlay>
{activeField ? (
<div className="border-brand-dark rounded-md border bg-white p-2 text-sm shadow-lg">
<span className="font-medium">{activeField.name}</span>
<span className="ml-2 text-xs text-slate-500">({activeField.type})</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}

View File

@@ -1,243 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { transformToUnifySurvey } from "./lib";
vi.mock("@formbricks/types/surveys/validation", () => ({
getTextContent: (str: string) => str,
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (val: Record<string, string>, _lang: string) => val?.default ?? "",
}));
vi.mock("@/lib/survey/utils", () => ({
getElementsFromBlocks: (blocks: Array<{ elements: unknown[] }>) =>
blocks.flatMap((block) => block.elements),
}));
vi.mock("@/lib/utils/recall", () => ({
recallToHeadline: (headline: Record<string, string>) => headline,
}));
const NOW = new Date("2026-02-24T10:00:00.000Z");
const createMockSurvey = (overrides: Partial<TSurvey> = {}): TSurvey =>
({
id: "survey-1",
name: "Test Survey",
status: "inProgress",
createdAt: NOW,
blocks: [
{
elements: [
{
id: "el-text",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "What do you think?" },
required: true,
},
{
id: "el-nps",
type: TSurveyElementTypeEnum.NPS,
headline: { default: "How likely to recommend?" },
required: false,
},
],
},
],
...overrides,
}) as unknown as TSurvey;
describe("transformToUnifySurvey", () => {
test("transforms a survey with basic elements", () => {
const result = transformToUnifySurvey(createMockSurvey());
expect(result).toEqual({
id: "survey-1",
name: "Test Survey",
status: "active",
createdAt: NOW,
elements: [
{
id: "el-text",
type: TSurveyElementTypeEnum.OpenText,
headline: "What do you think?",
required: true,
},
{
id: "el-nps",
type: TSurveyElementTypeEnum.NPS,
headline: "How likely to recommend?",
required: false,
},
],
});
});
test("filters out CTA elements", () => {
const survey = createMockSurvey({
blocks: [
{
elements: [
{
id: "el-text",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Feedback" },
required: true,
},
{
id: "el-cta",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "Click here" },
required: false,
},
],
},
],
} as Partial<TSurvey>);
const result = transformToUnifySurvey(survey);
expect(result.elements).toHaveLength(1);
expect(result.elements[0].id).toBe("el-text");
});
test("defaults required to false when not set", () => {
const survey = createMockSurvey({
blocks: [
{
elements: [
{
id: "el-1",
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rate us" },
},
],
},
],
} as Partial<TSurvey>);
const result = transformToUnifySurvey(survey);
expect(result.elements[0].required).toBe(false);
});
test("falls back to 'Untitled' when headline is empty", () => {
const survey = createMockSurvey({
blocks: [
{
elements: [
{
id: "el-1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "" },
required: false,
},
],
},
],
} as Partial<TSurvey>);
const result = transformToUnifySurvey(survey);
expect(result.elements[0].headline).toBe("Untitled");
});
describe("mapSurveyStatus", () => {
test("maps 'inProgress' to 'active'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "inProgress" } as Partial<TSurvey>));
expect(result.status).toBe("active");
});
test("maps 'paused' to 'paused'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "paused" } as Partial<TSurvey>));
expect(result.status).toBe("paused");
});
test("maps 'draft' to 'draft'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "draft" } as Partial<TSurvey>));
expect(result.status).toBe("draft");
});
test("maps 'completed' to 'completed'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "completed" } as Partial<TSurvey>));
expect(result.status).toBe("completed");
});
test("maps unknown status to 'draft'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "archived" } as Partial<TSurvey>));
expect(result.status).toBe("draft");
});
});
test("handles multiple blocks", () => {
const survey = createMockSurvey({
blocks: [
{
elements: [
{
id: "el-1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: true,
},
],
},
{
elements: [
{ id: "el-2", type: TSurveyElementTypeEnum.Rating, headline: { default: "Q2" }, required: false },
],
},
],
} as Partial<TSurvey>);
const result = transformToUnifySurvey(survey);
expect(result.elements).toHaveLength(2);
expect(result.elements[0].id).toBe("el-1");
expect(result.elements[1].id).toBe("el-2");
});
test("handles empty blocks", () => {
const survey = createMockSurvey({ blocks: [] } as Partial<TSurvey>);
const result = transformToUnifySurvey(survey);
expect(result.elements).toEqual([]);
});
test("preserves all element types except CTA", () => {
const elementTypes = [
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.NPS,
TSurveyElementTypeEnum.Rating,
TSurveyElementTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.MultipleChoiceMulti,
TSurveyElementTypeEnum.Date,
TSurveyElementTypeEnum.Consent,
TSurveyElementTypeEnum.Matrix,
TSurveyElementTypeEnum.Ranking,
TSurveyElementTypeEnum.PictureSelection,
TSurveyElementTypeEnum.ContactInfo,
TSurveyElementTypeEnum.Address,
TSurveyElementTypeEnum.FileUpload,
TSurveyElementTypeEnum.Cal,
TSurveyElementTypeEnum.CTA,
];
const survey = createMockSurvey({
blocks: [
{
elements: elementTypes.map((type, i) => ({
id: `el-${i.toString()}`,
type,
headline: { default: `Question ${i.toString()}` },
required: false,
})),
},
],
} as Partial<TSurvey>);
const result = transformToUnifySurvey(survey);
const resultTypes = result.elements.map((e) => e.type);
expect(resultTypes).not.toContain(TSurveyElementTypeEnum.CTA);
expect(result.elements).toHaveLength(elementTypes.length - 1);
});
});

View File

@@ -1,51 +0,0 @@
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { recallToHeadline } from "@/lib/utils/recall";
import { TUnifySurvey, TUnifySurveyElement } from "./types";
const getElementHeadline = (element: TSurveyElement, survey: TSurvey): string => {
return (
getTextContent(
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
) || "Untitled"
);
};
const mapSurveyStatus = (status: string): TUnifySurvey["status"] => {
switch (status) {
case "inProgress":
return "active";
case "paused":
return "paused";
case "draft":
return "draft";
case "completed":
return "completed";
default:
return "draft";
}
};
export const transformToUnifySurvey = (survey: TSurvey): TUnifySurvey => {
const elements = getElementsFromBlocks(survey.blocks);
const unifySurveyElements: TUnifySurveyElement[] = elements
.filter((el) => el.type !== TSurveyElementTypeEnum.CTA)
.map((el) => ({
id: el.id,
type: el.type,
headline: getElementHeadline(el, survey),
required: el.required ?? false,
}));
return {
id: survey.id,
name: survey.name,
status: mapSurveyStatus(survey.status),
elements: unifySurveyElements,
createdAt: survey.createdAt,
};
};

View File

@@ -1,26 +0,0 @@
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getSurveys } from "@/lib/survey/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ConnectorsSection } from "./components/connectors-page-client";
import { transformToUnifySurvey } from "./lib";
export default async function UnifySourcesPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
await getEnvironmentAuth(params.environmentId);
const [connectors, surveys] = await Promise.all([
getConnectorsWithMappings(params.environmentId),
getSurveys(params.environmentId),
]);
const unifySurveys = surveys.map(transformToUnifySurvey);
return (
<ConnectorsSection
environmentId={params.environmentId}
initialConnectors={connectors}
initialSurveys={unifySurveys}
/>
);
}

View File

@@ -1,212 +0,0 @@
import { TFunction } from "i18next";
import { z } from "zod";
import { THubFieldType, ZHubFieldType } from "@formbricks/types/connector";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
export interface TUnifySurveyElement {
id: string;
type: TSurveyElementTypeEnum;
headline: string;
required: boolean;
}
export interface TUnifySurvey {
id: string;
name: string;
status: "draft" | "active" | "paused" | "completed";
elements: TUnifySurveyElement[];
createdAt: Date;
}
export interface TFieldMapping {
targetFieldId: string;
sourceFieldId?: string;
staticValue?: string;
}
export type TTargetFieldType = "string" | "enum" | "timestamp" | "float64" | "boolean" | "jsonb" | "string[]";
export interface TTargetField {
id: string;
name: string;
type: TTargetFieldType;
required: boolean;
description: string;
enumValues?: THubFieldType[];
exampleStaticValues?: string[];
}
export interface TSourceField {
id: string;
name: string;
type: string;
sampleValue?: string;
}
export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
{
id: "collected_at",
name: "Collected At",
type: "timestamp",
required: true,
description: "When the feedback was originally collected",
},
{
id: "source_type",
name: "Source Type",
type: "string",
required: true,
description: "Type of source (e.g., survey, review, support)",
},
{
id: "field_id",
name: "Field ID",
type: "string",
required: true,
description: "Unique question/field identifier",
},
{
id: "field_type",
name: "Field Type",
type: "enum",
required: true,
description: "Data type (text, nps, csat, rating, etc.)",
enumValues: ZHubFieldType.options,
},
{
id: "tenant_id",
name: "Tenant ID",
type: "string",
required: false,
description: "Tenant/organization identifier for multi-tenant deployments",
},
{
id: "source_id",
name: "Source ID",
type: "string",
required: false,
description: "Reference to survey/form/ticket/review ID",
},
{
id: "source_name",
name: "Source Name",
type: "string",
required: false,
description: "Human-readable source name for display",
},
{
id: "field_label",
name: "Field Label",
type: "string",
required: false,
description: "Question text or field label for display",
},
{
id: "field_group_id",
name: "Field Group ID",
type: "string",
required: false,
description: "Stable identifier grouping related fields (for ranking, matrix, grid questions)",
},
{
id: "field_group_label",
name: "Field Group Label",
type: "string",
required: false,
description: "Human-readable question text for the group",
},
{
id: "value_text",
name: "Value (Text)",
type: "string",
required: false,
description: "Text responses (feedback, comments, open-ended answers)",
},
{
id: "value_number",
name: "Value (Number)",
type: "float64",
required: false,
description: "Numeric responses (ratings, scores, NPS, CSAT)",
},
{
id: "value_boolean",
name: "Value (Boolean)",
type: "boolean",
required: false,
description: "Yes/no responses",
},
{
id: "value_date",
name: "Value (Date)",
type: "timestamp",
required: false,
description: "Date/datetime responses",
},
{
id: "metadata",
name: "Metadata",
type: "jsonb",
required: false,
description: "Flexible context (device, location, campaign, custom fields)",
},
{
id: "language",
name: "Language",
type: "string",
required: false,
description: "ISO 639-1 language code (e.g., en, de, fr)",
exampleStaticValues: ["en", "de", "fr", "es", "pt", "ja", "zh"],
},
{
id: "user_identifier",
name: "User Identifier",
type: "string",
required: false,
description: "Anonymous user ID for tracking (hashed, never PII)",
},
];
export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category";
export const MAX_CSV_VALUES = {
FILE_SIZE: 2_097_152, // 2MB (2 * 1024 * 1024)
RECORDS: 1_000, // 1,000 records
} as const;
export const createFeedbackCSVDataSchema = (t: TFunction) =>
z
.array(z.record(z.string(), z.string()))
.min(1, { message: t("environments.unify.csv_at_least_one_row") })
.max(MAX_CSV_VALUES.RECORDS, {
message: t("environments.unify.csv_max_records", {
max: MAX_CSV_VALUES.RECORDS.toLocaleString(),
}),
})
.superRefine((rows, ctx) => {
const localeSort = (a: string, b: string) => a.localeCompare(b);
const firstRowKeys = Object.keys(rows[0]).sort(localeSort).join(",");
for (let i = 1; i < rows.length; i++) {
const rowKeys = Object.keys(rows[i]).sort(localeSort).join(",");
if (rowKeys !== firstRowKeys) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("environments.unify.csv_inconsistent_columns", { row: (i + 1).toString() }),
});
return;
}
}
const emptyHeaders = Object.keys(rows[0]).filter((k) => k.trim() === "");
if (emptyHeaders.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("environments.unify.csv_empty_column_headers"),
});
}
});
export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSchema>>;
export type TCreateConnectorStep = "selectType" | "mapping";

View File

@@ -1,111 +0,0 @@
import { describe, expect, test } from "vitest";
import { MAX_CSV_VALUES, TSourceField } from "./types";
import { getConnectorOptions, parseCSVColumnsToFields, validateCsvFile } from "./utils";
const mockT = (key: string) => key;
describe("getConnectorOptions", () => {
test("returns formbricks and csv options", () => {
const options = getConnectorOptions(mockT as never);
expect(options).toHaveLength(2);
expect(options[0].id).toBe("formbricks");
expect(options[1].id).toBe("csv");
});
test("both options are enabled by default", () => {
const options = getConnectorOptions(mockT as never);
expect(options.every((o) => !o.disabled)).toBe(true);
});
test("uses translation keys for name and description", () => {
const options = getConnectorOptions(mockT as never);
expect(options[0].name).toBe("environments.unify.formbricks_surveys");
expect(options[0].description).toBe("environments.unify.source_connect_formbricks_description");
expect(options[1].name).toBe("environments.unify.csv_import");
expect(options[1].description).toBe("environments.unify.source_connect_csv_description");
});
});
describe("parseCSVColumnsToFields", () => {
test("parses comma-separated column names into source fields", () => {
const result = parseCSVColumnsToFields("name,email,score");
expect(result).toHaveLength(3);
expect(result).toEqual<TSourceField[]>([
{ id: "name", name: "name", type: "string", sampleValue: "Sample name" },
{ id: "email", name: "email", type: "string", sampleValue: "Sample email" },
{ id: "score", name: "score", type: "string", sampleValue: "Sample score" },
]);
});
test("trims whitespace from column names", () => {
const result = parseCSVColumnsToFields(" name , email , score ");
expect(result[0].id).toBe("name");
expect(result[1].id).toBe("email");
expect(result[2].id).toBe("score");
});
test("handles single column", () => {
const result = parseCSVColumnsToFields("feedback");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("feedback");
});
test("generates sample values from column names", () => {
const result = parseCSVColumnsToFields("rating,comment");
expect(result[0].sampleValue).toBe("Sample rating");
expect(result[1].sampleValue).toBe("Sample comment");
});
});
const createMockFile = (name: string, size: number, type: string): File =>
new File(["x".repeat(size)], name, { type });
describe("validateCsvFile", () => {
test("accepts a valid .csv file", () => {
const file = createMockFile("data.csv", 1024, "text/csv");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: true });
});
test("rejects a file without .csv extension", () => {
const file = createMockFile("data.xlsx", 1024, "text/csv");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: false, error: "environments.unify.csv_files_only" });
});
test("rejects a file with wrong MIME type", () => {
const file = createMockFile("data.csv", 1024, "application/json");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: false, error: "environments.unify.csv_files_only" });
});
test("accepts a .csv file with empty MIME type", () => {
const file = createMockFile("data.csv", 1024, "");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: true });
});
test("accepts a .csv file with alternative csv MIME type", () => {
const file = createMockFile("report.csv", 512, "application/csv");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: true });
});
test("rejects a file exceeding the size limit", () => {
const file = createMockFile("big.csv", MAX_CSV_VALUES.FILE_SIZE + 1, "text/csv");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: false, error: "environments.unify.csv_file_too_large" });
});
test("accepts a file exactly at the size limit", () => {
const file = createMockFile("exact.csv", MAX_CSV_VALUES.FILE_SIZE, "text/csv");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: true });
});
test("checks extension before MIME type", () => {
const file = createMockFile("data.txt", 100, "text/csv");
const result = validateCsvFile(file, mockT as never);
expect(result).toEqual({ valid: false, error: "environments.unify.csv_files_only" });
});
});

View File

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

View File

@@ -21,6 +21,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
let result: string[] = [];
@@ -256,10 +257,16 @@ const processElementResponse = (
const selectedChoiceIds = responseValue as string[];
return element.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => choice.imageUrl)
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
.join("\n");
}
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
return responseValue
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
.join("; ");
}
return processResponseData(responseValue);
};
@@ -368,7 +375,7 @@ const buildNotionPayloadProperties = (
responses[resp] = (pictureElement as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => choice.imageUrl);
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
}
});

View File

@@ -8,7 +8,6 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { handleConnectorPipeline } from "@/lib/connector/pipeline-handler";
import { CRON_SECRET } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
@@ -19,6 +18,7 @@ import { convertDatesInObject } from "@/lib/time";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
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";
@@ -96,12 +96,15 @@ export const POST = async (request: Request) => {
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
const webhookPromises = webhooks.map((webhook) => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
data: resolvedResponseData,
survey: {
title: survey.name,
type: survey.type,
@@ -142,14 +145,6 @@ export const POST = async (request: Request) => {
});
if (event === "responseFinished") {
// Handle connector pipeline for Hub integration (only on responseFinished to avoid duplicates)
// This sends response data to the Hub for configured connectors
try {
await handleConnectorPipeline(response, survey, environmentId);
} catch (error) {
// Log but don't throw - connector failures shouldn't break the main pipeline
logger.error({ error, surveyId, responseId: response.id }, "Connector pipeline failed");
}
// Fetch integrations and responseCount in parallel
const [integrations, responseCount] = await Promise.all([
getIntegrations(environmentId),

View File

@@ -1,5 +1,6 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response";
import {
GOOGLE_SHEETS_CLIENT_ID,
@@ -8,7 +9,7 @@ import {
WEBAPP_URL,
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -42,33 +43,39 @@ export const GET = async (req: Request) => {
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
let key;
let userEmail;
if (code) {
const token = await oAuth2Client.getToken(code);
key = token.res?.data;
// Set credentials using the provided token
oAuth2Client.setCredentials({
access_token: key.access_token,
});
// Fetch user's email
const oauth2 = google.oauth2({
auth: oAuth2Client,
version: "v2",
});
const userInfo = await oauth2.userinfo.get();
userEmail = userInfo.data.email;
if (!code) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
}
const token = await oAuth2Client.getToken(code);
const key = token.res?.data;
if (!key) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
}
oAuth2Client.setCredentials({ access_token: key.access_token });
const oauth2 = google.oauth2({ auth: oAuth2Client, version: "v2" });
const userInfo = await oauth2.userinfo.get();
const userEmail = userInfo.data.email;
if (!userEmail) {
return responses.internalServerErrorResponse("Failed to get user email");
}
const integrationType = "googleSheets" as const;
const existingIntegration = await getIntegrationByType(environmentId, integrationType);
const existingConfig = existingIntegration?.config as TIntegrationGoogleSheetsConfig;
const googleSheetIntegration = {
type: "googleSheets" as "googleSheets",
type: integrationType,
environment: environmentId,
config: {
key,
data: [],
data: existingConfig?.data ?? [],
email: userEmail,
},
};

View File

@@ -10,6 +10,7 @@ import {
TJsEnvironmentStateSurvey,
} from "@formbricks/types/js";
import { validateInputs } from "@/lib/utils/validate";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
/**
@@ -177,14 +178,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
overlay: environmentData.project.overlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: environmentData.project.styling,
styling: resolveStorageUrlsInObject(environmentData.project.styling),
},
},
organization: {
id: environmentData.project.organization.id,
billing: environmentData.project.organization.billing,
},
surveys: transformedSurveys,
surveys: resolveStorageUrlsInObject(transformedSurveys),
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
};
} catch (error) {

View File

@@ -82,7 +82,8 @@ const mockOrganization: TOrganization = {
},
periodStart: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockSurveys: TSurvey[] = [

View File

@@ -44,13 +44,10 @@ const validateResponse = (
...responseUpdateInput.data,
};
const isFinished = responseUpdateInput.finished ?? false;
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
isFinished,
survey.questions
);

View File

@@ -41,7 +41,6 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);

View File

@@ -6,7 +6,7 @@ import {
} from "@formbricks/types/integration/slack";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
@@ -56,6 +56,7 @@ export const GET = withV1ApiWrapper({
code,
client_id: SLACK_CLIENT_ID,
client_secret: SLACK_CLIENT_SECRET,
redirect_uri: SLACK_REDIRECT_URI,
};
const formBody: string[] = [];
for (const property in formData) {

View File

@@ -10,7 +10,7 @@ import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
async function fetchAndAuthorizeResponse(
@@ -57,7 +57,10 @@ export const GET = withV1ApiWrapper({
}
return {
response: responses.successResponse(result.response),
response: responses.successResponse({
...result.response,
data: resolveStorageUrlsInObject(result.response.data),
}),
};
} catch (error) {
return {
@@ -146,7 +149,6 @@ export const PUT = withV1ApiWrapper({
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
responseUpdate.finished,
result.survey.questions
);
@@ -190,7 +192,7 @@ export const PUT = withV1ApiWrapper({
}
return {
response: responses.successResponse(updated),
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
};
} catch (error) {
return {

View File

@@ -9,7 +9,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import {
createResponseWithQuotaEvaluation,
getResponses,
@@ -54,7 +54,9 @@ export const GET = withV1ApiWrapper({
allResponses.push(...environmentResponses);
}
return {
response: responses.successResponse(allResponses),
response: responses.successResponse(
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
),
};
} catch (error) {
if (error instanceof DatabaseError) {
@@ -155,7 +157,6 @@ export const POST = withV1ApiWrapper({
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
responseInput.finished,
surveyResult.survey.questions
);

View File

@@ -16,6 +16,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
const fetchAndAuthorizeSurvey = async (
surveyId: string,
@@ -58,16 +59,18 @@ export const GET = withV1ApiWrapper({
if (shouldTransformToQuestions) {
return {
response: responses.successResponse({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
}),
response: responses.successResponse(
resolveStorageUrlsInObject({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
})
),
};
}
return {
response: responses.successResponse(result.survey),
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
};
} catch (error) {
return {
@@ -202,12 +205,12 @@ export const PUT = withV1ApiWrapper({
};
return {
response: responses.successResponse(surveyWithQuestions),
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
};
}
return {
response: responses.successResponse(updatedSurvey),
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
};
} catch (error) {
return {

View File

@@ -43,7 +43,8 @@ const mockOrganization: TOrganization = {
},
periodStart: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = {

View File

@@ -14,6 +14,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
@@ -55,7 +56,7 @@ export const GET = withV1ApiWrapper({
});
return {
response: responses.successResponse(surveysWithQuestions),
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
};
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -112,7 +112,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);

View File

@@ -4854,6 +4854,17 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
}),
isDraft: true,
},
{
...buildOpenTextElement({
id: "preview-open-text-01",
headline: t("templates.preview_survey_question_open_text_headline"),
subheader: t("templates.preview_survey_question_open_text_subheader"),
placeholder: t("templates.preview_survey_question_open_text_placeholder"),
inputType: "text",
required: false,
}),
isDraft: true,
},
],
buttonLabel: createI18nString(t("templates.next"), []),
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),

View File

@@ -257,6 +257,7 @@ describe("endpoint-validator", () => {
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
expect(isAuthProtectedRoute("/")).toBe(false);
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
expect(isAuthProtectedRoute("/health")).toBe(false);
});
@@ -312,6 +313,19 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/c")).toBe(false);
expect(isPublicDomainRoute("/contact/token")).toBe(false);
});
test("should return true for pretty URL survey routes", () => {
expect(isPublicDomainRoute("/p/pretty123")).toBe(true);
expect(isPublicDomainRoute("/p/pretty-name-with-dashes")).toBe(true);
expect(isPublicDomainRoute("/p/survey_id_with_underscores")).toBe(true);
expect(isPublicDomainRoute("/p/abc123def456")).toBe(true);
});
test("should return false for malformed pretty URL survey routes", () => {
expect(isPublicDomainRoute("/p/")).toBe(false);
expect(isPublicDomainRoute("/p")).toBe(false);
expect(isPublicDomainRoute("/pretty/123")).toBe(false);
});
test("should return true for client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
@@ -375,6 +389,8 @@ describe("endpoint-validator", () => {
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
expect(isAdminDomainRoute("/p/pretty123")).toBe(false);
expect(isAdminDomainRoute("/p/pretty-name-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
});
@@ -390,6 +406,7 @@ describe("endpoint-validator", () => {
test("should allow public routes on public domain", () => {
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
@@ -426,6 +443,8 @@ describe("endpoint-validator", () => {
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
expect(isRouteAllowedForDomain("/p/pretty123", false)).toBe(false);
expect(isRouteAllowedForDomain("/p/pretty-name-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
});
@@ -440,6 +459,8 @@ describe("endpoint-validator", () => {
test("should handle paths with query parameters and fragments", () => {
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
});
@@ -450,6 +471,7 @@ describe("endpoint-validator", () => {
describe("URL parsing edge cases", () => {
test("should handle paths with query parameters", () => {
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
@@ -458,12 +480,14 @@ describe("endpoint-validator", () => {
test("should handle paths with fragments", () => {
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123#section")).toBe(true);
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
});
test("should handle trailing slashes", () => {
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
isManagementApi: true,
@@ -478,6 +502,9 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/preview")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/embed")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/thank-you")).toBe(true);
});
test("should handle nested client API routes", () => {
@@ -529,6 +556,7 @@ describe("endpoint-validator", () => {
test("should handle special characters in survey IDs", () => {
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true);
});
});
@@ -536,6 +564,7 @@ describe("endpoint-validator", () => {
test("should properly validate malicious or injection-like URLs", () => {
// SQL injection-like attempts
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
expect(isPublicDomainRoute("/p/'; DROP TABLE users; --")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
@@ -543,10 +572,12 @@ describe("endpoint-validator", () => {
// Path traversal attempts
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
// XSS-like attempts
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
expect(isPublicDomainRoute("/p/<script>alert('xss')</script>")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
isClientSideApi: true,
isRateLimited: true,
@@ -556,6 +587,7 @@ describe("endpoint-validator", () => {
test("should handle URL encoding", () => {
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
isManagementApi: true,
@@ -591,6 +623,7 @@ describe("endpoint-validator", () => {
// These should not match due to case sensitivity
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
isClientSideApi: false,
isRateLimited: true,

View File

@@ -7,6 +7,7 @@ const PUBLIC_ROUTES = {
SURVEY_ROUTES: [
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
/^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
],
// API routes accessible from public domain

View File

@@ -106,6 +106,7 @@ 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/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
@@ -114,7 +115,6 @@ checksums:
common/app_survey: f076d131d20bfdadb35fba29c8275232
common/apply_filters: 6543c1e80038b3da0f4a42848d08d4d1
common/are_you_sure: 6d5cd13628a7887711fd0c29f1123652
common/ask: 24150ae04c60dcd8688d93a8a3a2d238
common/attributes: 86d0ae6fea0fbb119722ed3841f8385a
common/back: f541015a827e37cb3b1234e56bc2aa3c
common/billing: b01dbdd049ebbd4a349fa64d6ce65a3b
@@ -123,6 +123,8 @@ checksums:
common/bottom_right: aaef9a70ef795affc806c6d1853d8373
common/cancel: 2e2a849c2223911717de8caa2c71bade
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
common/charts: 1da4564d89264c89de4ed28d7451b43e
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
@@ -137,7 +139,7 @@ checksums:
common/code: 343bc5386149b97cece2b093c39034b2
common/collapse_rows: 24988527f9180f37aa55d2aa183ccb21
common/completed: 0e4bbce9985f25eb673d9a054c8d5334
common/configure: e3ab18ebb36c218cd4897c620f5809ac
common/configuration: 923ec0502721489202f6222dd4107163
common/confirm: 90930b51154032f119fa75c1bd422d8b
common/connect: 8778ee245078a8be4a2ce855c8c56edc
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
@@ -152,6 +154,7 @@ checksums:
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
common/count_responses: 690118a456c01c5b4d437ae82b50b131
common/create: 757ccd28dd533ff3a933355273c1e32a
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
common/create_survey: 1cfbba08d34876566d84b2960054a987
@@ -161,6 +164,8 @@ 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
@@ -173,7 +178,6 @@ checksums:
common/disallow: 01c8ed3ce545ed836d3ccffc562c8a0c
common/discard: de83a114a79d086e372c43dbfe9f47b4
common/dismissed: f0e21b3fe28726c577a7238a63cc29c2
common/distribute: 0b702c85b5d4069d8367cb461c2ee0b1
common/docs: 1563fcb5ddb5037b0709ccd3dd384a92
common/documentation: 1563fcb5ddb5037b0709ccd3dd384a92
common/domain: 402d46965eacc3af4c5df92e53e95712
@@ -184,7 +188,6 @@ checksums:
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
common/enable: 463972a7a95f50f3105d09b92508f2cd
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
@@ -194,14 +197,16 @@ checksums:
common/error: 3c95bcb32c2104b99a46f5b3dd015248
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
common/error_component_title: ae68fa341a143aaa13a5ea30dd57a63e
common/error_loading_data: aaeffbfe4a2c2145442a57de524494be
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
common/failed_to_parse_csv: 7a3d675ecbb3d15884faf1006a5752d6
common/filter: 626325a05e4c8800f7ede7012b0cadaf
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/first_name: cf040a5d6a9fd696be400380cc99f54b
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
@@ -213,7 +218,9 @@ checksums:
common/hidden: fa290c6ada5869d744ed35e9cca64699
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
common/hide: a6088b934651055bb27314d111be510b
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
common/id: c8886d38aeea2ed5f785aba4fc96784b
common/image: 048ba7a239de0fbd883ade8558415830
common/images: 9305827c28694866f49db42b4c51831f
common/import: 348b8ab981de5b7f1fca6d7302263bbd
@@ -231,6 +238,7 @@ checksums:
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
common/label: a5c71bf158481233f8215dbd38cc196b
common/language: 277fd1a41cc237a437cd1d5e4a80463b
common/last_name: 2c9a7de7738ca007ba9023c385149c26
common/learn_more: e598091d132f890c37a6d4ed94f6d794
common/license_expired: 7af13535e320e4197989472c01387d2c
common/light_overlay: 0499907ea7b8405f4267b117998b5a78
@@ -257,6 +265,7 @@ checksums:
common/move_down: 4f4de55743043355ad4a839aff2c48ff
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
common/my_product: ad022177062f9ef6e9acf33b13e889aa
common/name: 9368b5a047572b6051f334af5aa76819
common/new: 126d036fae5fb6b629728ecb97e6195b
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
@@ -280,6 +289,7 @@ 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
@@ -405,7 +415,7 @@ checksums:
common/top_right: 241f95c923846911aaf13af6109333e5
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
common/type: f04471a7ddac844b9ad145eb9911ef75
common/unify: bdb518a1e62f51049ccc4366b909fb0a
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
common/update: 079fc039262fd31b10532929685c2d1b
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
@@ -422,6 +432,7 @@ checksums:
common/variables: ffd3eec5497af36d7b4e4185bad1313a
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
common/video: 8050c90e4289b105a0780f0fdda6ff66
common/view: 36a9b5e3dc153c036d320460d72a03c3
common/warning: 6618da2c7e5e93bb4ea0e16d29ab8c4c
common/we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable: f29f2e0286195dab170b9806bcd74fc9
common/webhook: 70f95b2c27f2c3840b500fcaf79ee83c
@@ -579,6 +590,170 @@ checksums:
environments/actions/you_can_track_code_action_anywhere_in_your_app_using: 3c0bbf160b8ddbeef142403103b70554
environments/actions/your_survey_would_be_shown_on_this_url: 766fdeeb52d170c156af5d035a1f8c37
environments/actions/your_survey_would_not_be_shown: af44fe160f449ff9557ebe5d3686832d
environments/analysis/charts/OR: 0208d355f231c386b19390f0bea41b95
environments/analysis/charts/add_chart_to_dashboard: c2a517ada86cdda60e49bec655ca9a6d
environments/analysis/charts/add_chart_to_dashboard_description: 08980a1849757e9aec21fca5881c6be4
environments/analysis/charts/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
environments/analysis/charts/add_to_dashboard: 9941c3d30895bb8e25ce8d4e03d33a08
environments/analysis/charts/advanced_chart_builder_config_prompt: c2fe2c1a076f27d3ae62a4db75474b0a
environments/analysis/charts/ai_query_placeholder: 24c3d18f514cb3a9953f04c3b04503a2
environments/analysis/charts/ai_query_section_description: 66d06342f29bf6658793403856521fd7
environments/analysis/charts/ai_query_section_title: c0e450a47af7c2a516b77f73cf54db1b
environments/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
environments/analysis/charts/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
environments/analysis/charts/chart_added_to_dashboard: 7bc429ab605cb89a9232c26be008cc00
environments/analysis/charts/chart_builder_choose_chart_type: 1376de2dcafac573a2df9e4c007b0ec8
environments/analysis/charts/chart_data: 6739a9576b357a58d73ff0c9bf8db0e4
environments/analysis/charts/chart_data_tab: b7b46ab6ce9606032c8f81f6f6afbb9b
environments/analysis/charts/chart_deleted_successfully: 79148f471cd9acc2c8d0d033fb85437e
environments/analysis/charts/chart_deletion_error: 267eb65c168e726075d7cea678dd32e0
environments/analysis/charts/chart_duplicated_successfully: 755c4ce5bf533764d549a53c33e32165
environments/analysis/charts/chart_duplication_error: 90d7166c85188b52f821c9d9f53ff8c4
environments/analysis/charts/chart_name: cdb36e2f121a7b9c28298e15ab8218dc
environments/analysis/charts/chart_name_placeholder: 7370d4f88f27aea337ba1c36465c3f8b
environments/analysis/charts/chart_preview: 1b7faae244d31e43f758f50b94132413
environments/analysis/charts/chart_render_error: 01e9ece0c86a1fedf301afa0dbbf6aeb
environments/analysis/charts/chart_saved_successfully: 2489c853c0b36790e3592ac6ea31cc61
environments/analysis/charts/chart_type_area: 535754c6425f045f17e1dcb551840c93
environments/analysis/charts/chart_type_bar: c11d460595d3ddfe8efd67ac068574c5
environments/analysis/charts/chart_type_big_number: 9d17fb96241507c955dca25e143ae67a
environments/analysis/charts/chart_type_line: f42dd53238ed4d44def306a61d47d5c4
environments/analysis/charts/chart_type_not_supported: 7ff0afc493b36f3f3c12c7c230df9757
environments/analysis/charts/chart_type_pie: 068a797404233ccf68d07ad63af7b50c
environments/analysis/charts/chart_updated_successfully: a2c210523902c726aa1328bbeda0b357
environments/analysis/charts/configure_description: 2939321f78e4ffbc57b4259ddaddb09d
environments/analysis/charts/configure_title: ab767b11da1d386b98b3f634f79d3abe
environments/analysis/charts/configure_type_label: cd13e4b37fb2021af55903e7690a9856
environments/analysis/charts/contains: 06dd606c0a8f81f9a03b414e9ae89440
environments/analysis/charts/create_chart: ca7fdcc964e01f42ea9709924221edba
environments/analysis/charts/create_chart_description: b9680bd8905dea180fa59a86f61de34e
environments/analysis/charts/custom_range: 99f4d72b64621406acc162cceeb1fed7
environments/analysis/charts/dashboard: c9380ea68c8c76ea451bd9613329a07c
environments/analysis/charts/dashboard_select_placeholder: 9b875f2f10050d650ae63be53fe0d4e8
environments/analysis/charts/data_label: b7b46ab6ce9606032c8f81f6f6afbb9b
environments/analysis/charts/date_preset_last_30_days: a738894cfc5e592052f1e16787744568
environments/analysis/charts/date_preset_last_7_days: 3631df3109bfecfe358ba15dcf8bd6f5
environments/analysis/charts/date_preset_last_month: 848086395b28875c050d56e3933dae61
environments/analysis/charts/date_preset_this_month: 50845a38865204a97773c44dcd2ebb90
environments/analysis/charts/date_preset_this_quarter: 9c77d94783dff2269c069389122cd7bd
environments/analysis/charts/date_preset_this_year: 1e69651c2ac722f8ce138f43cf2e02f9
environments/analysis/charts/date_preset_today: 142173f9752e18e92109623a3ee68cad
environments/analysis/charts/date_preset_yesterday: eeb58908e68ff96c1b7e8f90e389afb7
environments/analysis/charts/date_range: 9b3aa5954144de586931f60ef9594e99
environments/analysis/charts/delete_chart_confirmation: f7fd7b0a08e81c9b392b08c9c1ad2147
environments/analysis/charts/dimensions: f09d837ac25f58986a769bd48ea15022
environments/analysis/charts/dimensions_toggle_description: 50d1c6e73d2cb7320c9e29cec11b4c76
environments/analysis/charts/edit_chart_description: 822890e4b6068096e2fe8b7b78b4474f
environments/analysis/charts/edit_chart_title: fd3e7f8c53280bfad8f4034c055f4c71
environments/analysis/charts/enable_time_dimension: cfcf0af2d22bccd197319c07680c2cb8
environments/analysis/charts/end_date: acbea5a9fd7a6fadf5aa1b4f47188203
environments/analysis/charts/enter_a_name_for_your_chart: b6e992a23d0628136121ebf26eec4a50
environments/analysis/charts/enter_value: a4554ed67c02872e302b0042724f859d
environments/analysis/charts/equals: 264ec282f7f5b67da622cc37f2b57b8a
environments/analysis/charts/failed_to_add_chart_to_dashboard: 355a5606399edcbb3e6d0ba0b66f12a6
environments/analysis/charts/failed_to_execute_query: d1153133aa4cd3d1cd02e39942413168
environments/analysis/charts/failed_to_load_chart: abea098fbf8e728f95414d3ae8bb63a4
environments/analysis/charts/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
environments/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5
environments/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9
environments/analysis/charts/field_label_average_score: 5b5aa7322549521d1e813b1c8312d443
environments/analysis/charts/field_label_collected_at: b41902ddb4586ba4a4611d726b5014aa
environments/analysis/charts/field_label_count: 9c5848662eb8024ddf360f7e4001a968
environments/analysis/charts/field_label_detractor_count: eedb15bc383eb0f14d43043e6666c62a
environments/analysis/charts/field_label_emotion: eb3a31ead51b5c8a8d365d5f904e9206
environments/analysis/charts/field_label_field_type: 2581066dc304c853a4a817c20996fa08
environments/analysis/charts/field_label_nps_score: 9c8d0b0b460f9689bd66e81d45e0a2df
environments/analysis/charts/field_label_nps_value: cb7404025044400e3d7d5600f3133e4f
environments/analysis/charts/field_label_passive_count: ceb71da8d1382eb2097089dc3ecf76da
environments/analysis/charts/field_label_promoter_count: c393131a4bd3a25bf6b297beed20e34f
environments/analysis/charts/field_label_response_id: 73375099cc976dc7203b8e27f5f709e0
environments/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6
environments/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61
environments/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570
environments/analysis/charts/field_label_topic: 7f542b783cd528f00f4f485e35b48dc1
environments/analysis/charts/field_label_user_identifier: b0174469c95038766744fb7e64005aec
environments/analysis/charts/filters: acf5accc113ff3c1992688058576732c
environments/analysis/charts/filters_toggle_description: ea18bdb212a6a85620125cab89a4b1c1
environments/analysis/charts/generate_chart: 8b0ca95be31a8401b13eafa26cf01d31
environments/analysis/charts/granularity: 9eb09aef092e7803ce4acb7965cbbaa9
environments/analysis/charts/granularity_day: 47648cd60fc313bc3f05b70357a1d675
environments/analysis/charts/granularity_hour: ec3113f22fc51d01f0c615c5496f8f87
environments/analysis/charts/granularity_month: ae7bef950efc406ff0980affabc1a64c
environments/analysis/charts/granularity_quarter: 7a68ec90d7c90b92b7bb873834a00381
environments/analysis/charts/granularity_week: 436fdd694160827dd6ea4644cdd0a8f8
environments/analysis/charts/granularity_year: ed86f5f60583f9d8ffdbeed306aa0ec7
environments/analysis/charts/greater_than: a4c18b3b45fcaf7c83bf489cf2b506d4
environments/analysis/charts/greater_than_or_equal: d453e26d136847560148168797fece51
environments/analysis/charts/group_by: 3f1cedea7783018ce83f2fab0051a738
environments/analysis/charts/group_by_description: c54368c05d71c1bdbd2a5c0629c1dc03
environments/analysis/charts/guide_button: 3c5e2e28f6d9f1a644759c9c19878539
environments/analysis/charts/guide_chart_type: 1fd60a98a0b5a7f54521e7671772e4a3
environments/analysis/charts/guide_chart_type_desc: 4630292b6955c930a9c6d4169bf656a2
environments/analysis/charts/guide_dimensions: 746caf6f43a222f3ffdaae578323d36a
environments/analysis/charts/guide_dimensions_desc: 909a149ef47c2f811d65f437b34ea719
environments/analysis/charts/guide_filters: acf5accc113ff3c1992688058576732c
environments/analysis/charts/guide_filters_desc: 0c18f563b477cd9a0f2309c31174cd93
environments/analysis/charts/guide_measures: 2e4d2701ebb196e5a9122f03727e93d7
environments/analysis/charts/guide_measures_predefined: cb8b80a960a466aca9ad75d3e870f74b
environments/analysis/charts/guide_quick_ref: 6538588cf9323d85bf11b794448d846d
environments/analysis/charts/guide_term_dimension: 64bd5923ae7aa2cdbf967aca977e4945
environments/analysis/charts/guide_term_filter: c8dc27ccd08e7ec1e268dfd286660e79
environments/analysis/charts/guide_term_measure: ca94a6e1afcb8a7ddb0d79039ecd3bfb
environments/analysis/charts/guide_term_time: ddd5b6a7a0f8525b0fe2b7c3431319f2
environments/analysis/charts/guide_time_dimension: c6fed7f718296b2f23230a918bfe6196
environments/analysis/charts/guide_time_dimension_desc: 4565fd19f4346e0f0d52f79640d7d749
environments/analysis/charts/guide_title: e887f73e68a76c88fdef859bafc866a1
environments/analysis/charts/is_not_set: 906801489132487ef457652af4835142
environments/analysis/charts/is_set: 9850468156356f95884bbaf56b6687aa
environments/analysis/charts/less_than: fb41255dd44bb6de78617b078610c91b
environments/analysis/charts/less_than_or_equal: da4a2816aadf788d33efcdcc3c61802e
environments/analysis/charts/measures: b1e6cf0f356dda0052c4fef4ad4957a2
environments/analysis/charts/no_charts_found: d4a27d5b56e49ebdd38bf28791dbcc42
environments/analysis/charts/no_dashboards_available: f88389b6c5278cfc4d5b360031205dfe
environments/analysis/charts/no_dashboards_create_first: 28ded0d72247191eb23f6f77925df539
environments/analysis/charts/no_data_available: fe1d34a45e22b5611d255b84b2d67232
environments/analysis/charts/no_data_returned: 683acf7b4f3b32aa85fa26f1bb948d4f
environments/analysis/charts/no_data_returned_for_chart: b9ff6c85697c683f40b3d0c05eeb2046
environments/analysis/charts/no_grouping: e3a6943e61407600cae057e0833a482d
environments/analysis/charts/no_valid_data_to_display: d1ba2b0686520c0a2c62ee73daa1c9c9
environments/analysis/charts/not_contains: 5894f5474271b8902d7892e43500d227
environments/analysis/charts/not_equals: 427715f1ea349965c36f5c628784eb08
environments/analysis/charts/open_chart: bc3bed1517ad63c1bcccfbbc430ab333
environments/analysis/charts/open_options: 2c6a35fec9b9d008e41728594bcd07d7
environments/analysis/charts/or_filter_logic: 0208d355f231c386b19390f0bea41b95
environments/analysis/charts/original: 7e55782bdf7cb49f5616b326c003c278
environments/analysis/charts/please_enter_chart_name: 9258b71b2cb09d22ffe33de1755e7309
environments/analysis/charts/please_select_at_least_one_measure: d4163ede267f71ee65945f453e14ff7b
environments/analysis/charts/please_select_dashboard: 8f062db96f815ed8268584dd8d292fa6
environments/analysis/charts/predefined_measures: 7651141f62c991954edcff70899b2a8b
environments/analysis/charts/preset: a17bb0bf56f3326c9567be3ea896ee19
environments/analysis/charts/query_executed_successfully: 9d6f9dad526fcfe0161757c2d2fe2c69
environments/analysis/charts/reset_to_ai_suggestion: 51ced8dd7c0eea8b7fc4e08b35cfbf30
environments/analysis/charts/save_chart: 2e4505f7bf3d1c35b0b37b1e9d3dc566
environments/analysis/charts/save_chart_dialog_title: 2e4505f7bf3d1c35b0b37b1e9d3dc566
environments/analysis/charts/select_dimensions: 6d0d038d027ef9e641bf9b7700edac9f
environments/analysis/charts/select_field: 45665a44f7d5707506364f17f28db3bf
environments/analysis/charts/select_measures: c9f101aeb53bf0d4abdd652aaf60a1bf
environments/analysis/charts/select_preset: e68bad9a209a6ca35c62184f1f1d829c
environments/analysis/charts/showing_first_n_of: 4dec3215fd3150a16ad5c72f17ae02bc
environments/analysis/charts/start_date: 881de78c79b56f5ceb9b7103bf23cb2c
environments/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84
environments/analysis/charts/time_dimension_toggle_description: 28da119989e3c73b098c650fe279ee4a
environments/analysis/dashboards/create_dashboard: 9396aec1ea4a9b05ada94483655d1373
environments/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
environments/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
environments/analysis/dashboards/create_success: 1fa4dea7702ba03a8a3533295276ff1b
environments/analysis/dashboards/dashboard_name: a2d344bc03f27706b42d7d6a8d0fc752
environments/analysis/dashboards/dashboard_name_placeholder: 02954eeb5671f1c00e3f69b47319916e
environments/analysis/dashboards/delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
environments/analysis/dashboards/delete_failed: b108acc28b1f9abcb544a358a958b54b
environments/analysis/dashboards/delete_success: 9d161634daab9ea9d17fbfb413eeeffa
environments/analysis/dashboards/description_optional: d5519551a79f18fc414dc127b773485f
environments/analysis/dashboards/description_placeholder: 90a599e6b1695e2b026fb1300d1d5903
environments/analysis/dashboards/duplicate_failed: 6ebaf8ad373b156f88f1ed79a5efd441
environments/analysis/dashboards/duplicate_success: 37cbb14143776d4c215432673e32ebd9
environments/analysis/dashboards/no_dashboards_found: e049ec0356009c3a0aa2c729d916efc6
environments/analysis/dashboards/please_enter_name: b9211ed8a0882c0e0109beba48685d68
environments/connect/congrats: c2f5b597aabdf298cf9f0452863e2dc6
environments/connect/connection_successful_message: fa1f29883e15e8697c6c477bdf5cb645
environments/connect/do_it_later: ab4accfbe53d924ab3ffaf9ea78a75f3
@@ -614,7 +789,6 @@ checksums:
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
environments/contacts/create_key: 0d385c354af8963acbe35cd646710f86
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
environments/contacts/custom_attributes: fffc7722742d1291b102dc737cf2fc9e
@@ -625,6 +799,7 @@ checksums:
environments/contacts/delete_attribute_confirmation: 01d99b89eb3d27ff468d0db1b4aeb394
environments/contacts/delete_contact_confirmation: 2d45579e0bb4bc40fb1ee75b43c0e7a4
environments/contacts/delete_contact_confirmation_with_quotas: d3d17f13ae46ce04c126c82bf01299ac
environments/contacts/displays: fcc4527002bd045021882be463b8ac72
environments/contacts/edit_attribute: 92a83c96a5d850e7d39002e8fd5898f4
environments/contacts/edit_attribute_description: 073a3084bb2f3b34ed1320ed1cd6db3c
environments/contacts/edit_attribute_values: 44e4e7a661cc1b59200bb07c710072a7
@@ -636,6 +811,7 @@ checksums:
environments/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
environments/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
environments/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
environments/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
@@ -650,6 +826,8 @@ checksums:
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
environments/contacts/select_attribute_key: 673a6683fab41b387d921841cded7e38
environments/contacts/survey_viewed: 646d413218626787b0373ffd71cb7451
environments/contacts/survey_viewed_at: 2ab535237af5c3c3f33acc792a7e70a4
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
@@ -716,7 +894,12 @@ checksums:
environments/integrations/google_sheets/link_google_sheet: fa78146ae26ce5b1d2aaf2678f628943
environments/integrations/google_sheets/link_new_sheet: 8ad2ea8708f50ed184c00b84577b325e
environments/integrations/google_sheets/no_integrations_yet: ea46f7747937baf48a47a4c1b1776aee
environments/integrations/google_sheets/reconnect_button: 8992a0f250278c116cb26be448b68ba2
environments/integrations/google_sheets/reconnect_button_description: 851fd2fda57211293090f371d5b2c734
environments/integrations/google_sheets/reconnect_button_tooltip: 210dd97470fde8264d2c076db3c98fde
environments/integrations/google_sheets/spreadsheet_permission_error: 94f0007a187d3b9a7ab8200fe26aad20
environments/integrations/google_sheets/spreadsheet_url: b1665f96e6ecce23ea2d9196f4a3e5dd
environments/integrations/google_sheets/token_expired_error: 555d34c18c554ec8ac66614f21bd44fc
environments/integrations/include_created_at: 8011355b13e28e638d74e6f3d68a2bbf
environments/integrations/include_hidden_fields: 25f0ea5ca1c6ead2cd121f8754cb8d72
environments/integrations/include_metadata: 750091d965d7cc8d02468b5239816dc5
@@ -993,6 +1176,13 @@ checksums:
environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112
environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea
environments/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: 104d07b63a42911c9673ceb08a4dbd43
environments/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
environments/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
environments/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
environments/settings/general/ai_enabled_description: e8c3e9f362588898a6cea85e18c013a1
environments/settings/general/ai_settings_updated_successfully: 2a6f534dc3a246ced46becd8a4a9543d
environments/settings/general/ai_smart_tools_enabled: 1dda984f5262c5f9120ee9a409236758
environments/settings/general/ai_smart_tools_enabled_description: 1ceca6707746d3ab4a530712a06d91da
environments/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37
environments/settings/general/cannot_delete_only_organization: 833cc6848b28f2694a4552b4de91a6ba
environments/settings/general/cannot_leave_only_organization: dd8463262e4299fef7ad73512225c55b
@@ -1166,6 +1356,7 @@ checksums:
environments/surveys/edit/add_fallback_placeholder: 0e77ea487ddd7bc7fc2f1574b018dc08
environments/surveys/edit/add_hidden_field_id: a8f55b51b790cf5f4d898af7770ad1ed
environments/surveys/edit/add_highlight_border: 66f52b21fbb9aa6561c98a090abaaf8f
environments/surveys/edit/add_highlight_border_description: fe548fe03ea10ef5cd9e553d6812b3c2
environments/surveys/edit/add_logic: f234c9f1393a9ed4792dfbd15838c951
environments/surveys/edit/add_none_of_the_above: dbe1ada4512d6c3f80c54c8fac107ec6
environments/surveys/edit/add_option: 143c54f0b201067fe5159284d6daeca2
@@ -1364,7 +1555,6 @@ checksums:
environments/surveys/edit/follow_ups_modal_updated_successfull_toast: 61204fada3231f4f1fe3866e87e1130a
environments/surveys/edit/follow_ups_new: 224c779d252b3e75086e4ed456ba2548
environments/surveys/edit/follow_ups_upgrade_button_text: 4cd167527fc6cdb5b0bfc9b486b142a8
environments/surveys/edit/form_styling: 1278a2db4257b5500474161133acc857
environments/surveys/edit/formbricks_sdk_is_not_connected: 35165b0cac182a98408007a378cc677e
environments/surveys/edit/four_points: b289628a6b8a6cd0f7d17a14ca6cd7bf
environments/surveys/edit/heading: 79e9dfa461f38a239d34b9833ca103f1
@@ -1535,7 +1725,7 @@ checksums:
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
environments/surveys/edit/roundness_description: bde131aa5674836416dcdf2ff517d899
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
environments/surveys/edit/rows: 8f41f34e6ca28221cf1ebd948af4c151
environments/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
@@ -1581,6 +1771,7 @@ checksums:
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
environments/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
@@ -1851,6 +2042,7 @@ checksums:
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
environments/surveys/summary/impressions_identified_only: 10f8c491463c73b8e6534314ee00d165
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
environments/surveys/summary/in_app/connection_title: 29e8a40ad6a7fdb5af5ee9451a70a9aa
@@ -1891,6 +2083,7 @@ checksums:
environments/surveys/summary/last_quarter: 2e565a81de9b3d7b1ee709ebb6f6eda1
environments/surveys/summary/last_year: fe7c268a48bf85bc40da000e6e437637
environments/surveys/summary/limit: 347051f1a068e01e8c4e4f6744d8e727
environments/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
@@ -1933,95 +2126,6 @@ checksums:
environments/surveys/templates/multiple_industries: 7dcb6f6d87feb08f8004dfb5a91e711f
environments/surveys/templates/use_this_template: 69020c8b5a521b8f027616bb5c4b64dd
environments/surveys/templates/uses_branching_logic: 7ac087d7067d342c17809d4ce497dfe0
environments/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45
environments/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e
environments/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
environments/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
environments/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
environments/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
environments/unify/configure_import: 71d550661f7e9fe322b60e7e870aa2fd
environments/unify/configure_mapping: c794411c50bc511f8fc332def0e4e2f9
environments/unify/connection: 421e709602c92ffbe04a266f6a092089
environments/unify/connector_created_successfully: ea927316021fb2a41cc69ca3ec89d0aa
environments/unify/connector_deleted_successfully: ea3c9842c5b8f75b02ecb9c80c74d780
environments/unify/connector_duplicated_successfully: eb21ce42cdbef5fa38244206bf65fe4e
environments/unify/connector_status_updated_successfully: 443fd63b27f15a81ff146375adac739f
environments/unify/connector_updated_successfully: 11308c4a2881345209cefa06a3d90eab
environments/unify/create_mapping: cbe8c951e7819f574ca7d793920b2b60
environments/unify/created_by: 6775c2fa7d495fea48f1ad816daea93b
environments/unify/csv_at_least_one_row: 165bbc1853dde85c44eb5a587c52ce28
environments/unify/csv_columns: 280c5ba0b19ae5fa6d42f4d05a1771cb
environments/unify/csv_empty_column_headers: 6e9af154be54778cfca32296fbd23ecb
environments/unify/csv_file_too_large: e94c7a7c26096aae9eddb2db30c5cfc1
environments/unify/csv_files_only: 920612b537521b14c154f1ac9843e947
environments/unify/csv_import: ef4060fef24c4fec064987b9d2a9fa4b
environments/unify/csv_import_complete: e8b6306e62e10c128f6464176ba879dd
environments/unify/csv_import_duplicate_warning: 3bedb07a01939d6b4ad93f68b7adf0e5
environments/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6
environments/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
environments/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
environments/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
environments/unify/deselect_all: facf8871b2e84a454c6bfe40c2821922
environments/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
environments/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
environments/unify/edit_source_connection: eb42476becc8de3de4ca9626828573f0
environments/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
environments/unify/enter_value: 4f068bb59617975c1e546218373122cd
environments/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
environments/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
environments/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
environments/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a
environments/unify/historical_import_complete: f46f98bf4db63bf2993bfb234dc95f62
environments/unify/import_csv_data: e5f873b0e6116c5144677acf38607f2e
environments/unify/import_rows: d2963498a7d2766264c4d67db677e8ff
environments/unify/importing_data: a6d4478379a0faee05cd2c10ffe74984
environments/unify/importing_historical_data: f5be578704ec26dc4ec573309e9fff20
environments/unify/invalid_enum_values: e6ca8740dab72f64e8dc5780b5cffcc6
environments/unify/invalid_values_found: 5011dc9c0294a222033f9910ea919b8a
environments/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e
environments/unify/n_supported_questions: d75413d386441b5eb137a1ea191e4bd9
environments/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
environments/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
environments/unify/no_surveys_found: 649a2f29b4c34525778d9177605fb326
environments/unify/optional: 396fb9a0472daf401c392bdc3e248943
environments/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
environments/unify/question_selected: b9ff13b6212874258da911867932dc7d
environments/unify/question_type_not_supported: 8d9f7554e3b509dfd5307d8d1fef08d7
environments/unify/questions_selected: 1f13d6fecafa2ce5ea9e6d07078a1d38
environments/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
environments/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
environments/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20
environments/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862
environments/unify/select_all: eedc7cdb02de467c15dc418a066a77f2
environments/unify/select_questions: 13c79b8c284423eb6140534bf2137e56
environments/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc
environments/unify/select_source_type_prompt: c3fce7d908ee62b9e1b7fab1b17606d7
environments/unify/select_survey: bac52e59c7847417bef6fe7b7096b475
environments/unify/select_survey_and_questions: 53914988a2f48caecea23f3b3b868b9f
environments/unify/select_survey_questions_description: 3386ed56085eabebefa3cc453269fc5b
environments/unify/set_value: b8a86f8da957ebd599ece4b1b1936a78
environments/unify/setup_connection: cce7d9c488d737d04e70bed929a46f8a
environments/unify/showing_rows: 83d3440314d1e6f2721e034369a3a131
environments/unify/source: 45309626f464f4bda161ee783a4c8c80
environments/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746
environments/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
environments/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
environments/unify/source_name: 157675beca12efcd8ec512c5256b1a61
environments/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
environments/unify/sources: ecbbe6e49baa335c5afd7b04b609d006
environments/unify/status_active: 3de9afebcb9d4ce8ac42e14995f79ffd
environments/unify/status_completed: 0e4bbce9985f25eb673d9a054c8d5334
environments/unify/status_draft: e8a92958ad300aacfe46c2bf6644927e
environments/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248
environments/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
environments/unify/survey_has_no_questions: c08514b6bce5eb464a4492239be5934d
environments/unify/survey_import_line: 63fa0ea1d7daa3ba333436fbc65f8b19
environments/unify/total_feedback_records: 8962087650b62e4a12b81e7d09317ffa
environments/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
environments/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
environments/unify/updated_at: 8fdb85248e591254973403755dcc3724
environments/unify/upload_csv_data_description: 7fab46222ab05a4424db90a7cc96cdf5
environments/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
environments/workspace/api_keys/add_api_key: 3c7633bae18a6e19af7a5af12f9bc3da
environments/workspace/api_keys/api_key: ce825fec5b3e1f8e27c45b1a63619985
environments/workspace/api_keys/api_key_copied_to_clipboard: daeeac786ba09ffa650e206609b88f9c
@@ -2121,7 +2225,7 @@ checksums:
environments/workspace/look/advanced_styling_field_description_size: a0d51c3ab7dc56320ecedc2b27917842
environments/workspace/look/advanced_styling_field_description_size_description: ff880ea1beddd1b1ec7416d0b8a69cf3
environments/workspace/look/advanced_styling_field_description_weight: 514680cc7202ad29835c1cbcde3def1c
environments/workspace/look/advanced_styling_field_description_weight_description: 441ac8db1a32557813eb68fbfd759061
environments/workspace/look/advanced_styling_field_description_weight_description: aa95bc81b5336a548e256bce49350683
environments/workspace/look/advanced_styling_field_font_size: ca44d14429b2175a1b194793b4ab8f6b
environments/workspace/look/advanced_styling_field_font_weight: bfef83778146cf40550df9650d8a07da
environments/workspace/look/advanced_styling_field_headline_color: 4ccf3935ad90c88ad4add24f498673ce
@@ -2135,7 +2239,7 @@ checksums:
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
environments/workspace/look/advanced_styling_field_input_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
environments/workspace/look/advanced_styling_field_input_height_description: e19ec0dc432478def0fd1199ad765e38
environments/workspace/look/advanced_styling_field_input_height_description: bb7439d42ec3848a8fa9edb8b001b69a
environments/workspace/look/advanced_styling_field_input_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
@@ -2144,6 +2248,8 @@ checksums:
environments/workspace/look/advanced_styling_field_input_text_description: 460450df24ea0cc902710118a5000feb
environments/workspace/look/advanced_styling_field_option_bg: 0ceaed10d99ed4ad83cb0934ab970174
environments/workspace/look/advanced_styling_field_option_bg_description: 6cd6ccecbbb9f2f19439d7c682eb67c1
environments/workspace/look/advanced_styling_field_option_border: aa478eb148515b6a2637fb144ff72028
environments/workspace/look/advanced_styling_field_option_border_description: 8f75b740e8dcb7f6cfeff2e5d5ca7c92
environments/workspace/look/advanced_styling_field_option_border_radius_description: 23f81c25b2681a7c9e2c4f2e7d2e0656
environments/workspace/look/advanced_styling_field_option_font_size_description: 5430fd9b08819972f0a613bf3fa659da
environments/workspace/look/advanced_styling_field_option_label: 2767a5db32742073a01aac16488e93dc
@@ -2925,6 +3031,9 @@ checksums:
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
templates/prioritize_features_description: 1eae41fad0e3947f803d8539081e59ec
templates/prioritize_features_name: 4ca59ff1f9c319aaa68c3106d820fd6a

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,132 +0,0 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { createFeedbackRecordsBatch } from "@/modules/hub";
import { getConnectorsBySurveyId, updateConnector } from "./service";
import { transformResponseToFeedbackRecords } from "./transform";
const getErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error";
const logFailedRecords = (
connectorId: string,
results: Awaited<ReturnType<typeof createFeedbackRecordsBatch>>["results"]
): void => {
for (const [index, result] of results.entries()) {
if (!result.error) continue;
logger.error(
{
connectorId,
feedbackRecordIndex: index,
error: {
status: result.error.status,
message: result.error.message,
detail: result.error.detail,
},
},
"Failed to create FeedbackRecord"
);
}
};
const processConnector = async (
connector: TConnectorWithMappings,
response: TResponse,
survey: TSurvey,
environmentId: string
): Promise<void> => {
const feedbackRecords = transformResponseToFeedbackRecords(
response,
survey,
connector.formbricksMappings,
environmentId
);
if (feedbackRecords.length === 0) {
return;
}
const { results } = await createFeedbackRecordsBatch(feedbackRecords);
const successes = results.filter((r) => r.data !== null).length;
const failures = results.filter((r) => r.error !== null).length;
if (failures > 0) {
logger.warn(
{
connectorId: connector.id,
surveyId: survey.id,
responseId: response.id,
successes,
failures,
},
`Connector pipeline: ${failures}/${feedbackRecords.length} FeedbackRecords failed to send`
);
logFailedRecords(connector.id, results);
} else {
logger.info(
{
connectorId: connector.id,
surveyId: survey.id,
responseId: response.id,
feedbackRecordsCreated: successes,
},
`Connector pipeline: Successfully sent ${successes} FeedbackRecords`
);
}
if (successes > 0) {
await updateConnector(connector.id, environmentId, { lastSyncAt: new Date() });
}
};
/**
* Handle connector pipeline for a survey response
*
* This function is called from the pipeline when a response is created/finished.
* It looks up active connectors for the survey and sends the response data.
*
* @param response - The survey response
* @param survey - The survey
* @param environmentId - The environment ID (used as tenant_id)
*/
export const handleConnectorPipeline = async (
response: TResponse,
survey: TSurvey,
environmentId: string
): Promise<void> => {
try {
const connectors = await getConnectorsBySurveyId(survey.id);
if (connectors.length === 0) {
return;
}
for (const connector of connectors) {
try {
await processConnector(connector, response, survey, environmentId);
} catch (error) {
logger.error(
{
connectorId: connector.id,
surveyId: survey.id,
responseId: response.id,
error: getErrorMessage(error),
},
"Connector pipeline: Failed to process connector"
);
}
}
} catch (error) {
logger.error(
{
surveyId: survey.id,
responseId: response.id,
error: getErrorMessage(error),
},
"Connector pipeline: Failed to handle connectors"
);
}
};

View File

@@ -1,527 +0,0 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
createConnectorWithMappings,
deleteConnector,
getConnectorsBySurveyId,
getConnectorsWithMappings,
updateConnector,
updateConnectorWithMappings,
} from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
connector: {
findMany: vi.fn(),
findUniqueOrThrow: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
connectorFormbricksMapping: {
create: vi.fn(),
deleteMany: vi.fn(),
},
connectorFieldMapping: {
create: vi.fn(),
deleteMany: vi.fn(),
},
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
const ENV_ID = "clxxxxxxxxxxxxxxxx001";
const CONNECTOR_ID = "clxxxxxxxxxxxxxxxx002";
const SURVEY_ID = "clxxxxxxxxxxxxxxxx003";
const NOW = new Date("2026-02-24T10:00:00.000Z");
const mockConnector = {
id: CONNECTOR_ID,
createdAt: NOW,
updatedAt: NOW,
name: "Test Connector",
type: "formbricks" as const,
status: "active" as const,
environmentId: ENV_ID,
lastSyncAt: null,
createdBy: null,
};
const mockConnectorWithMappingsFromDb = {
...mockConnector,
creator: null,
formbricksMappings: [
{
id: "mapping-1",
createdAt: NOW,
connectorId: CONNECTOR_ID,
environmentId: ENV_ID,
surveyId: SURVEY_ID,
elementId: "el-1",
hubFieldType: "text",
customFieldLabel: null,
},
],
fieldMappings: [],
};
const mockConnectorWithMappings = {
...mockConnector,
creatorName: null,
formbricksMappings: mockConnectorWithMappingsFromDb.formbricksMappings,
fieldMappings: [],
};
describe("getConnectorsWithMappings", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns connectors for the given environment", async () => {
vi.mocked(prisma.connector.findMany).mockResolvedValue([mockConnectorWithMappingsFromDb] as never);
const result = await getConnectorsWithMappings(ENV_ID);
expect(prisma.connector.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { environmentId: ENV_ID },
orderBy: { createdAt: "desc" },
})
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(CONNECTOR_ID);
});
test("applies pagination when page is provided", async () => {
vi.mocked(prisma.connector.findMany).mockResolvedValue([] as never);
await getConnectorsWithMappings(ENV_ID, 2);
expect(prisma.connector.findMany).toHaveBeenCalledWith(
expect.objectContaining({
take: expect.any(Number),
skip: expect.any(Number),
})
);
});
test("returns empty array when no connectors exist", async () => {
vi.mocked(prisma.connector.findMany).mockResolvedValue([] as never);
const result = await getConnectorsWithMappings(ENV_ID);
expect(result).toEqual([]);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.connector.findMany).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("connection error", {
code: "P1001",
clientVersion: "5.0.0",
})
);
await expect(getConnectorsWithMappings(ENV_ID)).rejects.toThrow(DatabaseError);
});
});
describe("getConnectorsBySurveyId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns active formbricks connectors linked to the survey", async () => {
vi.mocked(prisma.connector.findMany).mockResolvedValue([mockConnectorWithMappingsFromDb] as never);
const result = await getConnectorsBySurveyId(SURVEY_ID);
expect(prisma.connector.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
type: "formbricks",
status: "active",
formbricksMappings: { some: { surveyId: SURVEY_ID } },
},
})
);
expect(result).toHaveLength(1);
});
test("returns empty when no connectors match", async () => {
vi.mocked(prisma.connector.findMany).mockResolvedValue([] as never);
const result = await getConnectorsBySurveyId(SURVEY_ID);
expect(result).toEqual([]);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.connector.findMany).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P1001",
clientVersion: "5.0.0",
})
);
await expect(getConnectorsBySurveyId(SURVEY_ID)).rejects.toThrow(DatabaseError);
});
});
describe("updateConnector", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("updates connector name and returns the result", async () => {
const updated = { ...mockConnector, name: "Renamed" };
vi.mocked(prisma.connector.update).mockResolvedValue(updated as never);
const result = await updateConnector(CONNECTOR_ID, ENV_ID, { name: "Renamed" });
expect(prisma.connector.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CONNECTOR_ID, environmentId: ENV_ID },
data: expect.objectContaining({ name: "Renamed" }),
})
);
expect(result.name).toBe("Renamed");
});
test("updates connector status", async () => {
const updated = { ...mockConnector, status: "paused" };
vi.mocked(prisma.connector.update).mockResolvedValue(updated as never);
const result = await updateConnector(CONNECTOR_ID, ENV_ID, { status: "paused" });
expect(result.status).toBe("paused");
});
test("throws ResourceNotFoundError when connector does not exist", async () => {
vi.mocked(prisma.connector.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Not found", {
code: "P2015",
clientVersion: "5.0.0",
})
);
await expect(updateConnector(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on generic Prisma error", async () => {
vi.mocked(prisma.connector.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P1001",
clientVersion: "5.0.0",
})
);
await expect(updateConnector(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(DatabaseError);
});
test("rethrows non-Prisma errors", async () => {
vi.mocked(prisma.connector.update).mockRejectedValue(new Error("unexpected"));
await expect(updateConnector(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow("unexpected");
});
});
describe("deleteConnector", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("deletes the connector and returns it", async () => {
vi.mocked(prisma.connector.delete).mockResolvedValue(mockConnector as never);
const result = await deleteConnector(CONNECTOR_ID, ENV_ID);
expect(prisma.connector.delete).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CONNECTOR_ID, environmentId: ENV_ID },
})
);
expect(result.id).toBe(CONNECTOR_ID);
});
test("throws ResourceNotFoundError when connector does not exist", async () => {
vi.mocked(prisma.connector.delete).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Not found", {
code: "P2015",
clientVersion: "5.0.0",
})
);
await expect(deleteConnector(CONNECTOR_ID, ENV_ID)).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on generic Prisma error", async () => {
vi.mocked(prisma.connector.delete).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P1001",
clientVersion: "5.0.0",
})
);
await expect(deleteConnector(CONNECTOR_ID, ENV_ID)).rejects.toThrow(DatabaseError);
});
});
describe("createConnectorWithMappings", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const setupTransaction = () => {
const txMethods = {
connector: {
create: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
connectorFormbricksMapping: {
create: vi.fn(),
},
connectorFieldMapping: {
create: vi.fn(),
},
};
vi.mocked(prisma.$transaction).mockImplementation(async (fn) => {
return (fn as (tx: typeof txMethods) => Promise<unknown>)(txMethods);
});
return txMethods;
};
test("creates connector without mappings", async () => {
const tx = setupTransaction();
tx.connector.create.mockResolvedValue({ id: CONNECTOR_ID, environmentId: ENV_ID });
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappingsFromDb);
const result = await createConnectorWithMappings(ENV_ID, { name: "New", type: "formbricks" });
expect(tx.connector.create).toHaveBeenCalledWith(
expect.objectContaining({
data: { name: "New", type: "formbricks", environmentId: ENV_ID },
})
);
expect(tx.connectorFormbricksMapping.create).not.toHaveBeenCalled();
expect(tx.connectorFieldMapping.create).not.toHaveBeenCalled();
expect(result).toEqual(mockConnectorWithMappings);
});
test("creates connector with formbricks mappings", async () => {
const tx = setupTransaction();
tx.connector.create.mockResolvedValue({ id: CONNECTOR_ID, environmentId: ENV_ID });
tx.connectorFormbricksMapping.create.mockResolvedValue({});
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappingsFromDb);
await createConnectorWithMappings(
ENV_ID,
{ name: "FB", type: "formbricks" },
{
type: "formbricks",
mappings: [
{ surveyId: SURVEY_ID, elementId: "el-1", hubFieldType: "text" },
{ surveyId: SURVEY_ID, elementId: "el-2", hubFieldType: "nps" },
],
}
);
expect(tx.connectorFormbricksMapping.create).toHaveBeenCalledTimes(2);
expect(tx.connectorFormbricksMapping.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
connectorId: CONNECTOR_ID,
environmentId: ENV_ID,
surveyId: SURVEY_ID,
elementId: "el-1",
hubFieldType: "text",
}),
})
);
});
test("creates connector with field mappings", async () => {
const tx = setupTransaction();
tx.connector.create.mockResolvedValue({ id: CONNECTOR_ID, environmentId: ENV_ID });
tx.connectorFieldMapping.create.mockResolvedValue({});
tx.connector.findUniqueOrThrow.mockResolvedValue({
...mockConnector,
formbricksMappings: [],
fieldMappings: [],
});
await createConnectorWithMappings(
ENV_ID,
{ name: "CSV", type: "csv" },
{
type: "field",
mappings: [{ sourceFieldId: "col-1", targetFieldId: "value_text" }],
}
);
expect(tx.connectorFieldMapping.create).toHaveBeenCalledTimes(1);
expect(tx.connectorFieldMapping.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
connectorId: CONNECTOR_ID,
environmentId: ENV_ID,
sourceFieldId: "col-1",
targetFieldId: "value_text",
}),
})
);
});
test("throws InvalidInputError on unique constraint violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
})
);
await expect(createConnectorWithMappings(ENV_ID, { name: "Dup", type: "formbricks" })).rejects.toThrow(
InvalidInputError
);
});
test("throws DatabaseError on generic Prisma error", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P1001",
clientVersion: "5.0.0",
})
);
await expect(createConnectorWithMappings(ENV_ID, { name: "Fail", type: "csv" })).rejects.toThrow(
DatabaseError
);
});
});
describe("updateConnectorWithMappings", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const setupTransaction = () => {
const txMethods = {
connector: {
update: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
connectorFormbricksMapping: {
create: vi.fn(),
deleteMany: vi.fn(),
},
connectorFieldMapping: {
create: vi.fn(),
deleteMany: vi.fn(),
},
};
vi.mocked(prisma.$transaction).mockImplementation(async (fn) => {
return (fn as (tx: typeof txMethods) => Promise<unknown>)(txMethods);
});
return txMethods;
};
test("updates connector name without changing mappings", async () => {
const tx = setupTransaction();
tx.connector.update.mockResolvedValue(undefined);
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappingsFromDb);
const result = await updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "Updated" });
expect(tx.connector.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CONNECTOR_ID, environmentId: ENV_ID },
data: expect.objectContaining({ name: "Updated" }),
})
);
expect(tx.connectorFormbricksMapping.deleteMany).not.toHaveBeenCalled();
expect(tx.connectorFieldMapping.deleteMany).not.toHaveBeenCalled();
expect(result).toEqual(mockConnectorWithMappings);
});
test("replaces formbricks mappings when provided", async () => {
const tx = setupTransaction();
tx.connector.update.mockResolvedValue(undefined);
tx.connectorFormbricksMapping.deleteMany.mockResolvedValue({ count: 1 });
tx.connectorFormbricksMapping.create.mockResolvedValue({});
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappingsFromDb);
await updateConnectorWithMappings(
CONNECTOR_ID,
ENV_ID,
{ name: "Updated" },
{
type: "formbricks",
mappings: [{ surveyId: SURVEY_ID, elementId: "el-new", hubFieldType: "nps" }],
}
);
expect(tx.connectorFormbricksMapping.deleteMany).toHaveBeenCalledWith({
where: { connectorId: CONNECTOR_ID, environmentId: ENV_ID },
});
expect(tx.connectorFormbricksMapping.create).toHaveBeenCalledTimes(1);
});
test("replaces field mappings when provided", async () => {
const tx = setupTransaction();
tx.connector.update.mockResolvedValue(undefined);
tx.connectorFieldMapping.deleteMany.mockResolvedValue({ count: 1 });
tx.connectorFieldMapping.create.mockResolvedValue({});
tx.connector.findUniqueOrThrow.mockResolvedValue({
...mockConnector,
formbricksMappings: [],
fieldMappings: [],
});
await updateConnectorWithMappings(
CONNECTOR_ID,
ENV_ID,
{ name: "CSV Updated" },
{
type: "field",
mappings: [{ sourceFieldId: "col-x", targetFieldId: "value_number" }],
}
);
expect(tx.connectorFieldMapping.deleteMany).toHaveBeenCalledWith({
where: { connectorId: CONNECTOR_ID, environmentId: ENV_ID },
});
expect(tx.connectorFieldMapping.create).toHaveBeenCalledTimes(1);
});
test("throws ResourceNotFoundError when connector does not exist", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Not found", {
code: "P2015",
clientVersion: "5.0.0",
})
);
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
ResourceNotFoundError
);
});
test("throws DatabaseError on generic Prisma error", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P1001",
clientVersion: "5.0.0",
})
);
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
DatabaseError
);
});
});

View File

@@ -1,366 +0,0 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import {
TConnector,
TConnectorCreateInput,
TConnectorFieldMappingCreateInput,
TConnectorFormbricksMappingCreateInput,
TConnectorUpdateInput,
TConnectorWithMappings,
ZConnectorCreateInput,
ZConnectorUpdateInput,
} from "@formbricks/types/connector";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
const selectConnectorWithMappings = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
status: true,
environmentId: true,
lastSyncAt: true,
createdBy: true,
creator: { select: { name: true } },
formbricksMappings: {
select: {
id: true,
createdAt: true,
connectorId: true,
environmentId: true,
surveyId: true,
elementId: true,
hubFieldType: true,
customFieldLabel: true,
},
},
fieldMappings: {
select: {
id: true,
createdAt: true,
connectorId: true,
environmentId: true,
sourceFieldId: true,
targetFieldId: true,
staticValue: true,
},
},
} satisfies Prisma.ConnectorSelect;
const selectConnector = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
status: true,
environmentId: true,
lastSyncAt: true,
createdBy: true,
} satisfies Prisma.ConnectorSelect;
type PrismaConnectorWithCreator = Prisma.ConnectorGetPayload<{ select: typeof selectConnectorWithMappings }>;
const mapConnectorWithMappings = (connector: PrismaConnectorWithCreator): TConnectorWithMappings => {
const { creator, ...rest } = connector;
return { ...rest, creatorName: creator?.name ?? null } as TConnectorWithMappings;
};
export const getConnectorsWithMappings = reactCache(
async (environmentId: string, page?: number): Promise<TConnectorWithMappings[]> => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const connectors = await prisma.connector.findMany({
where: {
environmentId,
},
select: selectConnectorWithMappings,
orderBy: {
createdAt: "desc",
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return connectors.map(mapConnectorWithMappings);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getConnectorWithMappingsById = reactCache(
async (connectorId: string, environmentId: string): Promise<TConnectorWithMappings | null> => {
validateInputs([connectorId, ZId], [environmentId, ZId]);
try {
const connector = await prisma.connector.findUnique({
where: {
id: connectorId,
environmentId,
},
select: selectConnectorWithMappings,
});
return connector ? mapConnectorWithMappings(connector) : null;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getConnectorsBySurveyId = reactCache(
async (surveyId: string): Promise<TConnectorWithMappings[]> => {
validateInputs([surveyId, ZId]);
try {
const connectors = await prisma.connector.findMany({
where: {
type: "formbricks",
status: "active",
formbricksMappings: {
some: {
surveyId,
},
},
},
select: selectConnectorWithMappings,
});
return connectors.map(mapConnectorWithMappings);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const updateConnector = async (
connectorId: string,
environmentId: string,
data: TConnectorUpdateInput
): Promise<TConnector> => {
validateInputs([connectorId, ZId], [data, ZConnectorUpdateInput], [environmentId, ZId]);
try {
const connector = await prisma.connector.update({
where: {
id: connectorId,
environmentId,
},
data: {
name: data.name,
status: data.status,
lastSyncAt: data.lastSyncAt,
},
select: selectConnector,
});
return connector as TConnector;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.RecordDoesNotExist) {
throw new ResourceNotFoundError("Connector", connectorId);
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteConnector = async (connectorId: string, environmentId: string): Promise<TConnector> => {
validateInputs([connectorId, ZId], [environmentId, ZId]);
try {
const connector = await prisma.connector.delete({
where: {
id: connectorId,
environmentId,
},
select: selectConnector,
});
return connector as TConnector;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.RecordDoesNotExist) {
throw new ResourceNotFoundError("Connector", connectorId);
}
throw new DatabaseError(error.message);
}
throw error;
}
};
// -- Composite functions --
export type TFormbricksMappingsInput = {
type: "formbricks";
mappings: TConnectorFormbricksMappingCreateInput[];
};
export type TFieldMappingsInput = {
type: "field";
mappings: TConnectorFieldMappingCreateInput[];
};
export type TMappingsInput = TFormbricksMappingsInput | TFieldMappingsInput;
export const createConnectorWithMappings = async (
environmentId: string,
data: TConnectorCreateInput,
mappingsInput?: TMappingsInput
): Promise<TConnectorWithMappings> => {
validateInputs([environmentId, ZId], [data, ZConnectorCreateInput]);
try {
const result = await prisma.$transaction(async (tx) => {
const connector = await tx.connector.create({
data: {
name: data.name,
type: data.type,
environmentId,
createdBy: data.createdBy,
},
});
if (mappingsInput?.type === "formbricks") {
await Promise.all(
mappingsInput.mappings.map((mapping) =>
tx.connectorFormbricksMapping.create({
data: {
connectorId: connector.id,
environmentId,
surveyId: mapping.surveyId,
elementId: mapping.elementId,
hubFieldType: mapping.hubFieldType,
customFieldLabel: mapping.customFieldLabel,
},
})
)
);
} else if (mappingsInput?.type === "field") {
await Promise.all(
mappingsInput.mappings.map((mapping) =>
tx.connectorFieldMapping.create({
data: {
connectorId: connector.id,
environmentId,
sourceFieldId: mapping.sourceFieldId,
targetFieldId: mapping.targetFieldId,
staticValue: mapping.staticValue,
},
})
)
);
}
return tx.connector.findUniqueOrThrow({
where: { id: connector.id },
select: selectConnectorWithMappings,
});
});
return mapConnectorWithMappings(result);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError(`Connector with name ${data.name} already exists`);
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const updateConnectorWithMappings = async (
connectorId: string,
environmentId: string,
data: TConnectorUpdateInput,
mappingsInput?: TMappingsInput
): Promise<TConnectorWithMappings> => {
validateInputs([connectorId, ZId], [data, ZConnectorUpdateInput], [environmentId, ZId]);
try {
const result = await prisma.$transaction(async (tx) => {
await tx.connector.update({
where: { id: connectorId, environmentId },
data: {
name: data.name,
status: data.status,
lastSyncAt: data.lastSyncAt,
},
});
if (mappingsInput?.type === "formbricks") {
await tx.connectorFormbricksMapping.deleteMany({
where: { connectorId, environmentId },
});
await Promise.all(
mappingsInput.mappings.map((mapping) =>
tx.connectorFormbricksMapping.create({
data: {
connectorId,
environmentId,
surveyId: mapping.surveyId,
elementId: mapping.elementId,
hubFieldType: mapping.hubFieldType,
customFieldLabel: mapping.customFieldLabel,
},
})
)
);
} else if (mappingsInput?.type === "field") {
await tx.connectorFieldMapping.deleteMany({
where: { connectorId, environmentId },
});
await Promise.all(
mappingsInput.mappings.map((mapping) =>
tx.connectorFieldMapping.create({
data: {
connectorId,
environmentId,
sourceFieldId: mapping.sourceFieldId,
targetFieldId: mapping.targetFieldId,
staticValue: mapping.staticValue,
},
})
)
);
}
return tx.connector.findUniqueOrThrow({
where: { id: connectorId },
select: selectConnectorWithMappings,
});
});
return mapConnectorWithMappings(result);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.RecordDoesNotExist) {
throw new ResourceNotFoundError("Connector", connectorId);
}
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,316 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TConnectorFormbricksMapping } from "@formbricks/types/connector";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { transformResponseToFeedbackRecords } from "./transform";
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (_val: Record<string, string>, _lang: string) => _val?.default ?? "",
}));
vi.mock("@formbricks/types/surveys/validation", () => ({
getTextContent: (str: string) => str,
}));
vi.mock("@/lib/survey/utils", () => ({
getElementsFromBlocks: (blocks: Array<{ elements: unknown[] }>) =>
blocks.flatMap((block) => block.elements),
}));
const NOW = new Date("2026-02-24T10:00:00.000Z");
const mockSurvey = {
id: "survey-1",
name: "Product Feedback",
blocks: [
{
elements: [
{ id: "el-text", type: "openText", headline: { default: "How can we improve?" } },
{ id: "el-nps", type: "nps", headline: { default: "How likely to recommend?" } },
{ id: "el-rating", type: "rating", headline: { default: "Rate your experience" } },
{ id: "el-date", type: "date", headline: { default: "When did you visit?" } },
{ id: "el-bool", type: "consent", headline: { default: "Do you agree?" } },
{
id: "el-multi",
type: "multipleChoiceMulti",
headline: { default: "Select features" },
},
],
},
],
} as unknown as TSurvey;
const mockResponse = {
id: "resp-1",
createdAt: NOW,
data: {
"el-text": "Great product!",
"el-nps": 9,
"el-rating": 4,
"el-date": "2026-01-15",
"el-bool": "true",
"el-multi": ["feat-a", "feat-b"],
},
language: "en",
contact: { userId: "user-42" },
} as unknown as TResponse;
const createMapping = (
overrides: Partial<TConnectorFormbricksMapping> &
Pick<TConnectorFormbricksMapping, "elementId" | "hubFieldType">
): TConnectorFormbricksMapping => ({
id: `mapping-${overrides.elementId}`,
createdAt: NOW,
connectorId: "conn-1",
environmentId: "env-1",
surveyId: "survey-1",
customFieldLabel: null,
...overrides,
});
const allMappings: TConnectorFormbricksMapping[] = [
createMapping({ elementId: "el-text", hubFieldType: "text" }),
createMapping({ elementId: "el-nps", hubFieldType: "nps" }),
createMapping({ elementId: "el-rating", hubFieldType: "rating" }),
createMapping({ elementId: "el-date", hubFieldType: "date" }),
createMapping({ elementId: "el-bool", hubFieldType: "boolean" }),
createMapping({ elementId: "el-multi", hubFieldType: "categorical" }),
];
describe("transformResponseToFeedbackRecords", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns empty array when response has no data", () => {
const emptyResponse = { ...mockResponse, data: null } as unknown as TResponse;
const result = transformResponseToFeedbackRecords(emptyResponse, mockSurvey, allMappings);
expect(result).toEqual([]);
});
test("returns empty array when no mappings match the survey", () => {
const otherSurveyMappings = allMappings.map((m) => ({ ...m, surveyId: "other-survey" }));
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, otherSurveyMappings);
expect(result).toEqual([]);
});
test("skips elements with empty string values", () => {
const response = {
...mockResponse,
data: { "el-text": "" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result).toEqual([]);
});
test("skips elements with undefined values", () => {
const response = {
...mockResponse,
data: { "el-nps": 9 },
} as unknown as TResponse;
const mappings = [
createMapping({ elementId: "el-text", hubFieldType: "text" }),
createMapping({ elementId: "el-nps", hubFieldType: "nps" }),
];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0].field_id).toBe("el-nps");
});
test("transforms text field correctly", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
source_type: "formbricks",
field_id: "el-text",
field_type: "text",
field_label: "How can we improve?",
source_id: "survey-1",
source_name: "Product Feedback",
value_text: "Great product!",
language: "en",
user_identifier: "user-42",
});
});
test("transforms nps field correctly", () => {
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0].value_number).toBe(9);
expect(result[0].field_type).toBe("nps");
});
test("transforms rating field correctly", () => {
const mappings = [createMapping({ elementId: "el-rating", hubFieldType: "rating" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0].value_number).toBe(4);
});
test("transforms date field to ISO string", () => {
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0].value_date).toBe(new Date("2026-01-15").toISOString());
});
test("transforms boolean field correctly", () => {
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0].value_boolean).toBe(true);
});
test("transforms categorical (multi-select) field to comma-separated text", () => {
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0].value_text).toBe("feat-a, feat-b");
});
test("uses customFieldLabel when provided", () => {
const mappings = [
createMapping({ elementId: "el-text", hubFieldType: "text", customFieldLabel: "Custom Label" }),
];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result[0].field_label).toBe("Custom Label");
});
test("sets collected_at from response createdAt", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result[0].collected_at).toBe(NOW.toISOString());
});
test("includes tenant_id when provided", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, "tenant-abc");
expect(result[0].tenant_id).toBe("tenant-abc");
});
test("omits tenant_id when not provided", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result[0].tenant_id).toBeUndefined();
});
test("omits language when response language is 'default'", () => {
const response = { ...mockResponse, language: "default" } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].language).toBeUndefined();
});
test("omits user_identifier when contact has no userId", () => {
const response = { ...mockResponse, contact: null } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].user_identifier).toBeUndefined();
});
test("transforms all mappings in a single call", () => {
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings);
expect(result).toHaveLength(6);
const fieldIds = result.map((r) => r.field_id);
expect(fieldIds).toEqual(["el-text", "el-nps", "el-rating", "el-date", "el-bool", "el-multi"]);
});
test("falls back to 'Untitled' for element with no headline", () => {
const survey = {
...mockSurvey,
blocks: [{ elements: [{ id: "el-bare", type: "openText" }] }],
} as unknown as TSurvey;
const response = {
...mockResponse,
data: { "el-bare": "some text" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-bare", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, survey, mappings);
expect(result[0].field_label).toBe("Untitled");
});
describe("convertValueToHubFields edge cases", () => {
test("parses numeric string for nps field", () => {
const response = {
...mockResponse,
data: { "el-nps": "7" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_number).toBe(7);
});
test("returns empty fields for non-parseable numeric string", () => {
const response = {
...mockResponse,
data: { "el-nps": "not-a-number" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_number).toBeUndefined();
});
test("handles object value for text field", () => {
const response = {
...mockResponse,
data: { "el-text": { nested: "value" } },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_text).toBe(JSON.stringify({ nested: "value" }));
});
test("handles invalid date string gracefully", () => {
const response = {
...mockResponse,
data: { "el-date": "not-a-date" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_date).toBeUndefined();
});
test("converts boolean string '1' to true", () => {
const response = {
...mockResponse,
data: { "el-bool": "1" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_boolean).toBe(true);
});
test("converts boolean string 'false' to false", () => {
const response = {
...mockResponse,
data: { "el-bool": "false" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_boolean).toBe(false);
});
test("handles array value for text field", () => {
const response = {
...mockResponse,
data: { "el-text": ["a", "b", "c"] },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_text).toBe("a, b, c");
});
test("handles single string value for categorical field", () => {
const response = {
...mockResponse,
data: { "el-multi": "single-choice" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_text).toBe("single-choice");
});
});
});

View File

@@ -1,129 +0,0 @@
import "server-only";
import { TConnectorFormbricksMapping, THubFieldType } from "@formbricks/types/connector";
import { TResponse, TResponseData, TResponseDataValue } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import type { FeedbackRecordCreateParams } from "@/modules/hub";
const getHeadlineFromElement = (element?: TSurveyElement): string => {
if (!element?.headline) return "Untitled";
const raw = getLocalizedValue(element.headline, "default");
return getTextContent(raw) || "Untitled";
};
function extractResponseValue(responseData: TResponseData, elementId: string): TResponseDataValue {
if (!responseData || typeof responseData !== "object") return undefined;
return responseData[elementId];
}
const convertValueToHubFields = (
value: TResponseDataValue,
hubFieldType: THubFieldType
): Partial<
Pick<FeedbackRecordCreateParams, "value_text" | "value_number" | "value_boolean" | "value_date">
> => {
if (value === undefined || value === null) {
return {};
}
switch (hubFieldType) {
case "text":
if (typeof value === "string") return { value_text: value };
if (Array.isArray(value)) return { value_text: value.join(", ") };
if (typeof value === "object") return { value_text: JSON.stringify(value) };
return { value_text: String(value) };
case "number":
case "rating":
case "nps":
case "csat":
case "ces":
if (typeof value === "number") return { value_number: value };
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (!Number.isNaN(parsed)) return { value_number: parsed };
}
return {};
case "boolean":
if (typeof value === "boolean") return { value_boolean: value };
if (typeof value === "string") {
return { value_boolean: value.toLowerCase() === "true" || value === "1" };
}
return {};
case "date":
if (typeof value === "string") {
const date = new Date(value);
if (!Number.isNaN(date.getTime())) return { value_date: date.toISOString() };
}
if (value instanceof Date) return { value_date: value.toISOString() };
return {};
case "categorical":
if (typeof value === "string") return { value_text: value };
if (Array.isArray(value)) return { value_text: value.join(", ") };
return { value_text: String(value) };
default:
return { value_text: typeof value === "string" ? value : String(value) };
}
};
/**
* Transform a Formbricks survey response into FeedbackRecord payloads.
* Called from the pipeline handler when a response is created/finished.
*/
export function transformResponseToFeedbackRecords(
response: TResponse,
survey: TSurvey,
mappings: TConnectorFormbricksMapping[],
tenantId?: string
): FeedbackRecordCreateParams[] {
const responseData = response.data;
if (!responseData) return [];
const surveyMappings = mappings.filter((m) => m.surveyId === survey.id);
const elements = getElementsFromBlocks(survey.blocks);
const elementMap = new Map(elements.map((el) => [el.id, el]));
const feedbackRecords: FeedbackRecordCreateParams[] = [];
for (const mapping of surveyMappings) {
const value = extractResponseValue(responseData, mapping.elementId);
if (value === undefined || value === null || value === "") continue;
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(elementMap.get(mapping.elementId));
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
const feedbackRecord: FeedbackRecordCreateParams = {
collected_at:
response.createdAt instanceof Date ? response.createdAt.toISOString() : String(response.createdAt),
source_type: "formbricks",
field_id: mapping.elementId,
field_type: mapping.hubFieldType,
source_id: survey.id,
source_name: survey.name,
field_label: fieldLabel,
...valueFields,
};
if (response.language && response.language !== "default") {
feedbackRecord.language = response.language;
}
if (tenantId) {
feedbackRecord.tenant_id = tenantId;
}
if (response.contact?.userId) {
feedbackRecord.user_identifier = response.contact.userId;
}
feedbackRecords.push(feedbackRecord);
}
return feedbackRecords;
}

View File

@@ -41,9 +41,6 @@ export const GITHUB_SECRET = env.GITHUB_SECRET;
export const GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID;
export const GOOGLE_CLIENT_SECRET = env.GOOGLE_CLIENT_SECRET;
export const HUB_API_URL = env.HUB_API_URL;
export const HUB_API_KEY = env.HUB_API_KEY;
export const AZUREAD_CLIENT_ID = env.AZUREAD_CLIENT_ID;
export const AZUREAD_CLIENT_SECRET = env.AZUREAD_CLIENT_SECRET;
export const AZUREAD_TENANT_ID = env.AZUREAD_TENANT_ID;
@@ -66,7 +63,8 @@ export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
export const SLACK_CLIENT_SECRET = env.SLACK_CLIENT_SECRET;
export const SLACK_CLIENT_ID = env.SLACK_CLIENT_ID;
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read`;
export const SLACK_REDIRECT_URI = `${WEBAPP_URL}/api/v1/integrations/slack/callback`;
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read&redirect_uri=${SLACK_REDIRECT_URI}`;
export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID;
export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET;

View File

@@ -1,9 +1,10 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TDisplay, TDisplayFilters } from "@formbricks/types/displays";
import { TDisplay, TDisplayFilters, TDisplayWithContact } from "@formbricks/types/displays";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
@@ -23,13 +24,12 @@ export const getDisplayCountBySurveyId = reactCache(
const displayCount = await prisma.display.count({
where: {
surveyId: surveyId,
...(filters &&
filters.createdAt && {
createdAt: {
gte: filters.createdAt.min,
lte: filters.createdAt.max,
},
}),
...(filters?.createdAt && {
createdAt: {
gte: filters.createdAt.min,
lte: filters.createdAt.max,
},
}),
},
});
return displayCount;
@@ -42,6 +42,97 @@ export const getDisplayCountBySurveyId = reactCache(
}
);
export const getDisplaysByContactId = reactCache(
async (contactId: string): Promise<Pick<TDisplay, "id" | "createdAt" | "surveyId">[]> => {
validateInputs([contactId, ZId]);
try {
const displays = await prisma.display.findMany({
where: { contactId },
select: {
id: true,
createdAt: true,
surveyId: true,
},
orderBy: { createdAt: "desc" },
});
return displays;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getDisplaysBySurveyIdWithContact = reactCache(
async (surveyId: string, limit?: number, offset?: number): Promise<TDisplayWithContact[]> => {
validateInputs(
[surveyId, ZId],
[limit, z.number().int().min(1).optional()],
[offset, z.number().int().nonnegative().optional()]
);
try {
const displays = await prisma.display.findMany({
where: {
surveyId,
contactId: { not: null },
},
select: {
id: true,
createdAt: true,
surveyId: true,
contact: {
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: { in: ["email", "userId"] },
},
},
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
},
},
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
});
return displays.map((display) => ({
id: display.id,
createdAt: display.createdAt,
surveyId: display.surveyId,
contact: display.contact
? {
id: display.contact.id,
attributes: display.contact.attributes.reduce(
(acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
},
{} as Record<string, string>
),
}
: null,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {

View File

@@ -0,0 +1,219 @@
import { mockDisplayId, mockSurveyId } from "./__mocks__/data.mock";
import { prisma } from "@/lib/__mocks__/database";
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { getDisplaysByContactId, getDisplaysBySurveyIdWithContact } from "../service";
const mockContactId = "clqnj99r9000008lebgf8734j";
const mockDisplaysForContact = [
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
},
{
id: "clqkr5smu000208jy50v6g5k5",
createdAt: new Date("2024-01-14T10:00:00Z"),
surveyId: "clqkr8dlv000308jybb08evgs",
},
];
const mockDisplaysWithContact = [
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
contact: {
id: mockContactId,
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "userId" }, value: "user-123" },
],
},
},
{
id: "clqkr5smu000208jy50v6g5k5",
createdAt: new Date("2024-01-14T10:00:00Z"),
surveyId: "clqkr8dlv000308jybb08evgs",
contact: {
id: "clqnj99r9000008lebgf8734k",
attributes: [{ attributeKey: { key: "userId" }, value: "user-456" }],
},
},
];
describe("getDisplaysByContactId", () => {
describe("Happy Path", () => {
test("returns displays for a contact ordered by createdAt desc", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysForContact as any);
const result = await getDisplaysByContactId(mockContactId);
expect(result).toEqual(mockDisplaysForContact);
expect(prisma.display.findMany).toHaveBeenCalledWith({
where: { contactId: mockContactId },
select: {
id: true,
createdAt: true,
surveyId: true,
},
orderBy: { createdAt: "desc" },
});
});
test("returns empty array when contact has no displays", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
const result = await getDisplaysByContactId(mockContactId);
expect(result).toEqual([]);
});
});
describe("Sad Path", () => {
test("throws a ValidationError if the contactId is invalid", async () => {
await expect(getDisplaysByContactId("not-a-cuid")).rejects.toThrow(ValidationError);
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(DatabaseError);
});
test("throws generic Error for other exceptions", async () => {
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(Error);
});
});
});
describe("getDisplaysBySurveyIdWithContact", () => {
describe("Happy Path", () => {
test("returns displays with contact attributes transformed", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysWithContact as any);
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
expect(result).toEqual([
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
contact: {
id: mockContactId,
attributes: { email: "test@example.com", userId: "user-123" },
},
},
{
id: "clqkr5smu000208jy50v6g5k5",
createdAt: new Date("2024-01-14T10:00:00Z"),
surveyId: "clqkr8dlv000308jybb08evgs",
contact: {
id: "clqnj99r9000008lebgf8734k",
attributes: { userId: "user-456" },
},
},
]);
});
test("calls prisma with correct where clause and pagination", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
expect(prisma.display.findMany).toHaveBeenCalledWith({
where: {
surveyId: mockSurveyId,
contactId: { not: null },
},
select: {
id: true,
createdAt: true,
surveyId: true,
contact: {
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: { in: ["email", "userId"] },
},
},
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
},
},
orderBy: { createdAt: "desc" },
take: 15,
skip: 0,
});
});
test("returns empty array when no displays found", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
expect(result).toEqual([]);
});
test("handles display with null contact", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue([
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
contact: null,
},
] as any);
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
expect(result).toEqual([
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
contact: null,
},
]);
});
});
describe("Sad Path", () => {
test("throws a ValidationError if the surveyId is invalid", async () => {
await expect(getDisplaysBySurveyIdWithContact("not-a-cuid")).rejects.toThrow(ValidationError);
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(DatabaseError);
});
test("throws generic Error for other exceptions", async () => {
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(Error);
});
});
});

View File

@@ -33,8 +33,6 @@ export const env = createEnv({
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
HTTP_PROXY: z.string().url().optional(),
HTTPS_PROXY: z.string().url().optional(),
HUB_API_URL: z.string().url(),
HUB_API_KEY: z.string().optional(),
IMPRINT_URL: z
.string()
.url()
@@ -163,8 +161,6 @@ export const env = createEnv({
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
HTTP_PROXY: process.env.HTTP_PROXY,
HTTPS_PROXY: process.env.HTTPS_PROXY,
HUB_API_URL: process.env.HUB_API_URL,
HUB_API_KEY: process.env.HUB_API_KEY,
IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INVITE_DISABLED: process.env.INVITE_DISABLED,

View File

@@ -0,0 +1,6 @@
/**
* Error codes returned by Google Sheets integration.
* Use these constants when comparing error responses to avoid typos and enable reuse.
*/
export const GOOGLE_SHEET_INTEGRATION_INVALID_GRANT = "invalid_grant";
export const GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION = "insufficient_permission";

View File

@@ -2,7 +2,12 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import {
AuthenticationError,
DatabaseError,
OperationNotAllowedError,
UnknownError,
} from "@formbricks/types/errors";
import {
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
@@ -11,8 +16,12 @@ import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
GOOGLE_SHEET_MESSAGE_LIMIT,
} from "@/lib/constants";
import { GOOGLE_SHEET_MESSAGE_LIMIT } from "@/lib/constants";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { truncateText } from "../utils/strings";
import { validateInputs } from "../utils/validate";
@@ -81,6 +90,17 @@ export const writeData = async (
}
};
export const validateGoogleSheetsConnection = async (
googleSheetIntegrationData: TIntegrationGoogleSheets
): Promise<void> => {
validateInputs([googleSheetIntegrationData, ZIntegrationGoogleSheets]);
const integrationData = structuredClone(googleSheetIntegrationData);
integrationData.config.data.forEach((data) => {
data.createdAt = new Date(data.createdAt);
});
await authorize(integrationData);
};
export const getSpreadsheetNameById = async (
googleSheetIntegrationData: TIntegrationGoogleSheets,
spreadsheetId: string
@@ -94,7 +114,17 @@ export const getSpreadsheetNameById = async (
return new Promise((resolve, reject) => {
sheets.spreadsheets.get({ spreadsheetId }, (err, response) => {
if (err) {
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
const msg = err.message?.toLowerCase() ?? "";
const isPermissionError =
msg.includes("permission") ||
msg.includes("caller does not have") ||
msg.includes("insufficient permission") ||
msg.includes("access denied");
if (isPermissionError) {
reject(new OperationNotAllowedError(GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION));
} else {
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
}
return;
}
const spreadsheetTitle = response.data.properties.title;
@@ -109,26 +139,70 @@ export const getSpreadsheetNameById = async (
}
};
const isInvalidGrantError = (error: unknown): boolean => {
const err = error as { message?: string; response?: { data?: { error?: string } } };
return (
typeof err?.message === "string" &&
err.message.toLowerCase().includes(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT)
);
};
/** Buffer in ms before expiry_date to consider token near-expired (5 minutes). */
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
const GOOGLE_TOKENINFO_URL = "https://www.googleapis.com/oauth2/v1/tokeninfo";
/**
* Verifies that the access token is still valid and not revoked (e.g. user removed app access).
* Returns true if token is valid, false if invalid/revoked.
*/
const isAccessTokenValid = async (accessToken: string): Promise<boolean> => {
try {
const res = await fetch(`${GOOGLE_TOKENINFO_URL}?access_token=${encodeURIComponent(accessToken)}`);
return res.ok;
} catch {
return false;
}
};
const authorize = async (googleSheetIntegrationData: TIntegrationGoogleSheets) => {
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const refresh_token = googleSheetIntegrationData.config.key.refresh_token;
oAuth2Client.setCredentials({
refresh_token,
});
const { credentials } = await oAuth2Client.refreshAccessToken();
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
type: "googleSheets",
config: {
data: googleSheetIntegrationData.config?.data ?? [],
email: googleSheetIntegrationData.config?.email ?? "",
key: credentials,
},
});
const key = googleSheetIntegrationData.config.key;
oAuth2Client.setCredentials(credentials);
const hasStoredCredentials =
key.access_token && key.expiry_date && key.expiry_date > Date.now() + TOKEN_EXPIRY_BUFFER_MS;
return oAuth2Client;
if (hasStoredCredentials && (await isAccessTokenValid(key.access_token))) {
oAuth2Client.setCredentials(key);
return oAuth2Client;
}
oAuth2Client.setCredentials({ refresh_token: key.refresh_token });
try {
const { credentials } = await oAuth2Client.refreshAccessToken();
const mergedCredentials = {
...credentials,
refresh_token: credentials.refresh_token ?? key.refresh_token,
};
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
type: "googleSheets",
config: {
data: googleSheetIntegrationData.config?.data ?? [],
email: googleSheetIntegrationData.config?.email ?? "",
key: mergedCredentials,
},
});
oAuth2Client.setCredentials(mergedCredentials);
return oAuth2Client;
} catch (error) {
if (isInvalidGrantError(error)) {
throw new AuthenticationError(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT);
}
throw error;
}
};

View File

@@ -40,7 +40,8 @@ describe("auth", () => {
},
periodStart: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);

View File

@@ -55,7 +55,8 @@ describe("Organization Service", () => {
periodStart: new Date(),
period: "monthly" as const,
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -110,7 +111,8 @@ describe("Organization Service", () => {
periodStart: new Date(),
period: "monthly" as const,
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
},
];
@@ -163,7 +165,8 @@ describe("Organization Service", () => {
periodStart: new Date(),
period: "monthly" as const,
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -224,7 +227,8 @@ describe("Organization Service", () => {
periodStart: new Date(),
period: "monthly" as const,
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [{ userId: "user1" }, { userId: "user2" }],
projects: [
@@ -256,7 +260,8 @@ describe("Organization Service", () => {
periodStart: expect.any(Date),
period: "monthly",
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
});
expect(prisma.organization.update).toHaveBeenCalledWith({

View File

@@ -25,7 +25,8 @@ export const select: Prisma.OrganizationSelect = {
updatedAt: true,
name: true,
billing: true,
isAIEnabled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true,
};

View File

@@ -22,6 +22,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { ITEMS_PER_PAGE } from "../constants";
@@ -408,9 +409,10 @@ export const getResponseDownloadFile = async (
if (survey.isVerifyEmailEnabled) {
headers.push("Verified Email");
}
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
const jsonData = getResponsesJson(
survey,
responses,
resolvedResponses,
elements,
userAttributes,
hiddenFields,

View File

@@ -60,6 +60,7 @@ export const getSuggestedColors = (brandColor: string = DEFAULT_BRAND_COLOR) =>
// Options (Radio / Checkbox)
"optionBgColor.light": inputBg,
"optionLabelColor.light": questionColor,
"optionBorderColor.light": inputBorder,
// Card
"cardBackgroundColor.light": cardBg,
@@ -138,6 +139,7 @@ export const STYLE_DEFAULTS: TProjectStyling = {
// Options
optionBgColor: { light: _colors["optionBgColor.light"] },
optionLabelColor: { light: _colors["optionLabelColor.light"] },
optionBorderColor: { light: _colors["optionBorderColor.light"] },
optionBorderRadius: 8,
optionPaddingX: 16,
optionPaddingY: 16,
@@ -169,6 +171,7 @@ export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Recor
const q = light("questionColor");
const b = light("brandColor");
const i = light("inputColor");
const inputBorder = light("inputBorderColor");
return {
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
@@ -179,6 +182,7 @@ export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Recor
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
...(inputBorder && !saved.optionBorderColor && { optionBorderColor: { light: inputBorder } }),
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
...(b && !saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
};
@@ -210,6 +214,7 @@ export const buildStylingFromBrandColor = (brandColor: string = DEFAULT_BRAND_CO
inputTextColor: { light: colors["inputTextColor.light"] },
optionBgColor: { light: colors["optionBgColor.light"] },
optionLabelColor: { light: colors["optionLabelColor.light"] },
optionBorderColor: { light: colors["optionBorderColor.light"] },
cardBackgroundColor: { light: colors["cardBackgroundColor.light"] },
cardBorderColor: { light: colors["cardBorderColor.light"] },
highlightBorderColor: { light: colors["highlightBorderColor.light"] },

View File

@@ -232,7 +232,8 @@ export const mockOrganizationOutput: TOrganization = {
name: "mock Organization",
createdAt: currentDate,
updatedAt: currentDate,
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
billing: {
stripeCustomerId: null,
plan: "free",

View File

@@ -73,7 +73,8 @@ describe("User Service", () => {
},
periodStart: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
{
id: "org2",
@@ -93,7 +94,8 @@ describe("User Service", () => {
},
periodStart: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];

View File

@@ -22,6 +22,9 @@ export type AuditLoggingCtx = {
quotaId?: string;
teamId?: string;
integrationId?: string;
chartId?: string;
dashboardId?: string;
dashboardWidgetId?: string;
};
export type ActionClientCtx = {

View File

@@ -9,7 +9,6 @@ import {
getFormattedErrorMessage,
getOrganizationIdFromActionClassId,
getOrganizationIdFromApiKeyId,
getOrganizationIdFromConnectorId,
getOrganizationIdFromContactId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId,
@@ -25,7 +24,6 @@ import {
getOrganizationIdFromWebhookId,
getProductIdFromContactId,
getProjectIdFromActionClassId,
getProjectIdFromConnectorId,
getProjectIdFromContactId,
getProjectIdFromEnvironmentId,
getProjectIdFromIntegrationId,
@@ -56,7 +54,6 @@ vi.mock("@/lib/utils/services", () => ({
getLanguage: vi.fn(),
getTeam: vi.fn(),
getTag: vi.fn(),
getConnector: vi.fn(),
}));
describe("Helper Utilities", () => {
@@ -385,31 +382,6 @@ describe("Helper Utilities", () => {
const orgId = await getOrganizationIdFromQuotaId("quota1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromConnectorId returns organization ID through environment and project", async () => {
vi.mocked(services.getConnector).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromConnectorId("connector1");
expect(orgId).toBe("org1");
expect(services.getConnector).toHaveBeenCalledWith("connector1");
expect(services.getEnvironment).toHaveBeenCalledWith("env1");
expect(services.getProject).toHaveBeenCalledWith("project1");
});
test("getOrganizationIdFromConnectorId throws error when connector not found", async () => {
vi.mocked(services.getConnector).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromConnectorId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(services.getConnector).toHaveBeenCalledWith("nonexistent");
});
});
describe("Project ID retrieval functions", () => {
@@ -615,27 +587,6 @@ describe("Helper Utilities", () => {
const projectId = await getProjectIdFromQuotaId("quota1");
expect(projectId).toBe("project1");
});
test("getProjectIdFromConnectorId returns project ID through environment", async () => {
vi.mocked(services.getConnector).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromConnectorId("connector1");
expect(projectId).toBe("project1");
expect(services.getConnector).toHaveBeenCalledWith("connector1");
expect(services.getEnvironment).toHaveBeenCalledWith("env1");
});
test("getProjectIdFromConnectorId throws error when connector not found", async () => {
vi.mocked(services.getConnector).mockResolvedValueOnce(null);
await expect(getProjectIdFromConnectorId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(services.getConnector).toHaveBeenCalledWith("nonexistent");
});
});
describe("Environment ID retrieval functions", () => {

View File

@@ -2,7 +2,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
getActionClass,
getApiKey,
getConnector,
getContact,
getEnvironment,
getIntegration,
@@ -330,22 +329,3 @@ export const isStringMatch = (query: string, value: string): boolean => {
return valueModified.includes(queryModified);
};
// Connector helpers
export const getOrganizationIdFromConnectorId = async (connectorId: string) => {
const connector = await getConnector(connectorId);
if (!connector) {
throw new ResourceNotFoundError("connector", connectorId);
}
return await getOrganizationIdFromEnvironmentId(connector.environmentId);
};
export const getProjectIdFromConnectorId = async (connectorId: string) => {
const connector = await getConnector(connectorId);
if (!connector) {
throw new ResourceNotFoundError("connector", connectorId);
}
return await getProjectIdFromEnvironmentId(connector.environmentId);
};

View File

@@ -8,7 +8,6 @@ import { getQuota as getQuotaService } from "@/modules/ee/quotas/lib/quotas";
import {
getActionClass,
getApiKey,
getConnector,
getContact,
getEnvironment,
getIntegration,
@@ -79,9 +78,6 @@ vi.mock("@formbricks/database", () => ({
contact: {
findUnique: vi.fn(),
},
connector: {
findUnique: vi.fn(),
},
segment: {
findUnique: vi.fn(),
},
@@ -560,46 +556,4 @@ describe("Service Functions", () => {
await expect(getSegment(segmentId)).rejects.toThrow(DatabaseError);
});
});
describe("getConnector", () => {
const connectorId = "connector123";
test("returns the connector when found", async () => {
const mockConnector = { environmentId: "env123" };
vi.mocked(prisma.connector.findUnique).mockResolvedValue(mockConnector);
const result = await getConnector(connectorId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.connector.findUnique).toHaveBeenCalledWith({
where: { id: connectorId },
select: { environmentId: true },
});
expect(result).toEqual(mockConnector);
});
test("returns null when connector not found", async () => {
vi.mocked(prisma.connector.findUnique).mockResolvedValue(null);
const result = await getConnector(connectorId);
expect(result).toBeNull();
});
test("throws DatabaseError when Prisma throws a known request error", async () => {
vi.mocked(prisma.connector.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getConnector(connectorId)).rejects.toThrow(DatabaseError);
});
test("rethrows unknown errors", async () => {
const unknownError = new Error("Something unexpected");
vi.mocked(prisma.connector.findUnique).mockRejectedValue(unknownError);
await expect(getConnector(connectorId)).rejects.toThrow(unknownError);
});
});
});

View File

@@ -329,25 +329,3 @@ export const getSegment = reactCache(async (segmentId: string): Promise<{ enviro
throw error;
}
});
export const getConnector = reactCache(
async (connectorId: string): Promise<{ environmentId: string } | null> => {
validateInputs([connectorId, ZId]);
try {
const connector = await prisma.connector.findUnique({
where: {
id: connectorId,
},
select: { environmentId: true },
});
return connector;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);

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