Compare commits

..

162 Commits

Author SHA1 Message Date
Johannes e82e0c87a4 fix: remove stale translation keys in PR6
Made-with: Cursor
2026-04-26 20:19:26 +02:00
Johannes f2f2defd10 fix: align Cube dev defaults with local Postgres
Update docker-compose dev defaults and env guidance so Cube connects to the local postgres service by default instead of a non-resolvable host.

Made-with: Cursor
2026-04-26 20:17:00 +02:00
Johannes 128e94e4cf fix: add missing dashboard chart translation keys
Add missing i18n strings for the add-chart label and create-new-chart action so dashboard dialogs render translated copy instead of raw key names.

Made-with: Cursor
2026-04-26 20:17:00 +02:00
Johannes ee0ea7caa6 chore: finalize api keys, translations, and docs updates
Apply remaining admin API key UI tweaks, add matching locale copy updates, and include supporting development documentation for the question-bank workflow.

Made-with: Cursor
2026-04-26 20:17:00 +02:00
Johannes 52dc64ffd2 fix: add missing analysis translation keys in PR5
Made-with: Cursor
2026-04-26 20:16:47 +02:00
Johannes bec3fa2dbd feat: make Add charts a primary dashboard action
Promote Add charts to a labeled primary button in the dashboard control bar and keep secondary actions in the icon toolbar for clearer chart-creation affordance.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes 18a3c4f0f7 fix: pass directories into dashboard detail chart dialogs
Load feedback record directories in dashboard detail page and thread them through dashboard detail and control bar components so chart dialogs no longer crash with an undefined directories reference.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes 6c61afec2f feat: refresh analysis charts and dashboard feedback gating
Unify chart create and edit flows, update dashboard chart interactions, and add feedback-record availability checks with dedicated empty-state handling across analysis entry points.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes ce4d9350e2 fix: add missing feedback record translation keys in PR4
Made-with: Cursor
2026-04-26 20:05:49 +02:00
Johannes 3e6f81268d fix: require frdId when refreshing feedback records
Made-with: Cursor
2026-04-26 19:54:30 +02:00
Johannes 8137de3c80 feat: integrate hub feedback records into unify workspace
Add hub-backed feedback record actions and UI flows under Unify so workspaces can list and manage feedback records from a dedicated drawer and table experience.

Made-with: Cursor
2026-04-26 19:54:30 +02:00
Johannes bf0ad45697 fix: add missing feedback-directory and unify translations
Made-with: Cursor
2026-04-26 19:54:23 +02:00
Johannes b1a4277ca8 feat: wire workspace settings to feedback record directories
Integrate feedback record directory selection into workspace settings and creation flows while updating workspace navigation components to expose the new workspace-level destinations.

Made-with: Cursor
2026-04-26 19:37:33 +02:00
Johannes 1876c13f52 fix: align unify locale keys and regenerate translations
Made-with: Cursor
2026-04-26 19:37:27 +02:00
Johannes 0623bb9ff5 fix: make feedback sources settings card compatible in PR2
Made-with: Cursor
2026-04-26 19:03:23 +02:00
Johannes d37cddaa7e feat: refactor feedback sources UI and routing
Rework the feedback sources flow with new source form helpers, question selection components, and a canonical feedback-sources route while retiring the legacy survey selector.

Made-with: Cursor
2026-04-26 18:55:25 +02:00
Johannes 24f632f9ce fix: align unify connector type literals with schema
Replace legacy formbricks type checks with formbricks_survey across source setup flows so TypeScript and connector creation paths stay consistent.

Made-with: Cursor
2026-04-26 17:14:13 +02:00
Johannes b041e3da86 fix: normalize connector response timestamps for Cube
Normalize imported response timestamps to ISO and fall back from createdAt to updatedAt so collected_at is always valid for time-series charts.

Made-with: Cursor
2026-04-24 12:57:00 +02:00
Johannes 8d91a3db62 fix: map csat and ces connector question types
Add missing hub field mappings for csat and ces survey elements and guard against unmappable selected elements so connector setup fails with a clear validation error instead of a Prisma crash.

Made-with: Cursor
2026-04-24 12:02:51 +02:00
Johannes c05e3f192d fix: accept legacy connector type in create action
Normalize legacy formbricks connector payloads to formbricks_survey during connector creation so client flows continue working while UI migrations roll out.

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

Made-with: Cursor
2026-04-24 10:56:57 +02:00
Dhruwang Jariwala bf592937f4 feat: AI-powered survey translation (#7793) 2026-04-24 12:55:36 +05:30
Bhagya Amarasinghe 7ed7101ac1 feat: adds feedback record directory auth to api keys (#7804) 2026-04-23 18:04:17 +05:30
pandeymangg 2e926936fb addressed feedback 2026-04-23 17:46:09 +05:30
Dhruwang a386451e6e refactor: consolidate imports in actions.ts
Merged the import statements for ZAITranslationField and translateFields from the translate-fields module to streamline the code structure.
2026-04-23 15:17:31 +05:30
Dhruwang f0bf111e7b fix: test 2026-04-23 15:08:18 +05:30
Dhruwang 8a57a5b74b addressed feedback 2026-04-23 15:05:16 +05:30
Dhruwang 434cb1d0d0 refactor: remove BullMQ background jobs from AI translation
Replace the async job queue + Redis polling pattern with a direct
server action call. The translation now runs synchronously inside
translateSurveyFieldsAction, removing the need for BullMQ job
definitions, processors, cache keys, and client-side polling logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:11:28 +05:30
pandeymangg 8bde75a9ff chore: merge with epic/v5 2026-04-23 11:06:54 +05:30
pandeymangg 6b880f29cb chore: merge with epic/v5 2026-04-23 11:00:25 +05:30
Dhruwang Jariwala 969c9834e5 fix: (Depr Env QA) update lang keys (#7786) 2026-04-23 10:57:47 +05:30
pandeymangg 5e33b7c9a4 fixes and e2e fixes 2026-04-23 10:55:20 +05:30
Dhruwang 230ea744fa fix: simplify workspace permissions assignment in ViewPermissionModal 2026-04-23 10:38:18 +05:30
Anshuman Pandey fae1fb8f96 fix: cleans up environmentId references (#7792) 2026-04-23 09:05:00 +04:00
Dhruwang eac35daed9 fix: transllations 2026-04-23 10:34:29 +05:30
Johannes 45accc1edb fix: align workspace naming in setup and email preview
Replace remaining environment wording with workspace terminology across setup flows, API key permissions, and email preview text, and switch the email Tailwind config to ESM so formatting hooks run under the current Node runtime.

Made-with: Cursor
2026-04-23 10:30:30 +05:30
Dhruwang Jariwala 02ebe8e9f8 fix: (Depr Env QA) update docs (#7784) 2026-04-23 10:23:23 +05:30
pandeymangg cae859e326 sonarcloud fixes 2026-04-22 17:15:19 +05:30
pandeymangg 5352d563b6 fixes and consistency 2026-04-22 17:03:18 +05:30
Anshuman Pandey 711f2bfe67 chore: restores feedback record directory changes to epic/v5 (#7806) 2026-04-22 15:22:15 +04:00
Dhruwang 6fcb5d39a2 fix: address code review feedback for AI translation
- Reuse ZAITranslationField from @formbricks/jobs instead of duplicating
  the schema locally in actions.ts; tighten sourceLanguage/targetLanguage
  validators with .min(1) to match the downstream job schema

- Guard against undefined translations in getAITranslationResultAction
  instead of using the unsafe `translations!` assertion — return
  "pending" status for malformed cache entries

- Use createCacheKey.custom("ai-translation", jobId) instead of raw
  template strings to follow cache key conventions

- Improve JSON parsing robustness: strip markdown code fences before
  attempting JSON.parse, log raw response on parse failures

- Add stale-request guard and error handling to the AI availability
  useEffect in language-view.tsx

- Replace shared pollingCancelledRef boolean with per-invocation Symbol
  token to prevent stale polling loops from clobbering state when the
  modal is reopened

- Track timeout explicitly with a timedOut flag so the "timed out" toast
  doesn't fire when polling was actually cancelled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 16:01:57 +05:30
pandeymangg 1ed9859ee7 fixes tests 2026-04-22 15:58:02 +05:30
pandeymangg cd72a0a78d adds translations 2026-04-22 15:44:45 +05:30
pandeymangg 2b09795787 feat: adds frd auth to api keys 2026-04-22 15:43:33 +05:30
Dhruwang 2451acb9bd fix: AI translation security, error handling, and test coverage
- Add userId verification in getAITranslationResultAction (security)
- Use OperationNotAllowedError for auth failures
- Store failure marker in cache on last BullMQ attempt
- Make JSON parsing more robust (extract first {...} block)
- Add "keep modal open" hint to translating toast
- Add test coverage for process-ai-translation-job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 15:18:54 +05:30
Dhruwang 14dcded91b Merge branch 'epic/v5' of https://github.com/formbricks/formbricks into feat/ai-survey-translation 2026-04-22 15:01:12 +05:30
Dhruwang 46062f91cd refactors 2026-04-22 14:50:46 +05:30
Dhruwang Jariwala ffd4478184 chore: merge epic/dashboards into epic/v5 (#7798)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Theodór Tómas <theodortomas@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: 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: Tiago <1585571+xernobyl@users.noreply.github.com>
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@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>
Co-authored-by: Tiago Farto <tiago@formbricks.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 12:24:05 +04:00
Dhruwang 69da1862fa Merge latest epic/v5 (survey scheduling) into ai-translation branch
Resolve merge conflicts to combine AI translation and survey scheduling
features in jobs package, instrumentation, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 13:39:10 +05:30
Dhruwang c11d3241ab fix: test and translations 2026-04-22 12:59:27 +05:30
Tiago 3fb09a1a26 feat: survey scheduling (#7766) 2026-04-21 15:00:51 +00:00
Dhruwang 6efa449c10 feat: add AI-powered survey translation via BullMQ background jobs
Add a "Translate with AI" button to the Manage Translations modal that
auto-populates empty translation fields using the configured AI provider.
Translation runs as a BullMQ background job with results cached in Redis
and polled by the client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 17:38:47 +05:30
Dhruwang 34b94689ca fix: address CodeRabbit review feedback on docs
- Fix broken API URLs in docs-feedback.mdx (remove /workspaces segment, fix https://, remove stray };)
- Add missing workspaceId path params to v2 spec (responses, displays, user)
- Remove environmentId from required arrays in v2 request schemas
- Fix stale terminology: environment→workspace in database-model, tenant-separation, tags, actions
- Fix broken link to removed test-environment page in webhooks.mdx
- Fix redundant "codebase" in naming-conventions description
- Use neutral hostname in audit-logging example
- Hyphenate "open-source" in license.mdx
- Consistent workspaceId formatting in wordpress.mdx
- Update link text to match anchor in actions.mdx
- Remove dual environmentId/workspaceId in headless-surveys example
- Fix stale <project_id> placeholder in headless-surveys
- Fix awkward Next.js card copy in framework-guides.mdx
- Clarify BC wording in v2 introduction.mdx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:35:26 +05:30
Tiago Farto 901fac7e08 chore: fix env vars 2026-04-21 09:56:32 +00:00
Dhruwang 739c662863 chore: merge epic/v5 and resolve openapi.json conflicts
Accept epic/v5 removals (attribute-classes, people endpoints) and
re-apply workspaceId rename + deprecation notes + endpoint cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:22:17 +05:30
Dhruwang 535974ff8a fix: align API specs with actual routes and add environment deprecation notes
- Restore v1 openapi.json from main with environmentId→workspaceId rename
- Remove 5 non-existent v1 endpoints and their orphaned MDX pages
- Update v1 descriptions from "environment" to "workspace" terminology
- Add environment deprecation notes to all v1 client API endpoints
- Remove 2 non-existent v2 client endpoints (contacts attributes, identify)
- Rename v2 project-teams → workspace-teams (path, operationIds, schema)
- Preserve environment deprecation notes in v2 spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:15:52 +05:30
Tiago Farto a8b97abe9a Merge remote-tracking branch 'origin/epic/v5' into feat/survey-scheduling
# Conflicts:
#	pnpm-lock.yaml
2026-04-21 09:37:41 +00:00
Johannes 28103604b4 fix: (Depr Env QA) api v1/me regression (#7761)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-21 13:26:01 +04:00
Tiago Farto b5a7e15386 chore: fix build 2026-04-21 09:07:45 +00:00
Tiago Farto fec4746d5d chore: address PR comments 2026-04-21 08:51:32 +00:00
pandeymangg 175323e7d9 chore: merge with main 2026-04-21 14:07:14 +05:30
Tiago Farto 6130737d51 chore: fix DST bug 2026-04-20 16:57:48 +00:00
Tiago Farto bf10a8d0b2 Merge branch 'epic/v5' into feat/survey-scheduling 2026-04-20 16:09:16 +00:00
Tiago Farto 612f8dceb8 chore: fix test 2026-04-20 15:50:38 +00:00
Tiago 0303f16db4 feat: BullMQ background jobs + response pipeline (#7779) 2026-04-20 15:30:20 +00:00
Tiago Farto 07635b160e chore: fix test; migration 2026-04-20 15:13:27 +00:00
Tiago Farto 9cfcffdb5e chore: bug fix; tests 2026-04-20 14:22:06 +00:00
Tiago Farto 02264ffc5f chore: build fix 2026-04-20 13:25:01 +00:00
Serhat e489c6a346 feat: Add Turkish (tr) translations (#7645)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-20 12:51:25 +00:00
Tiago Farto 7dde3edd8d chore: fix tests 2026-04-20 12:30:03 +00:00
Johannes 4304a7efd6 Rename Projects to Workspaces in docs 2026-04-20 13:56:27 +02:00
Tiago Farto a89d598f8d Merge branch 'epic/bullmq' into feat/survey-scheduling 2026-04-20 11:46:13 +00:00
Tiago Farto 6ff5af712f chore: clean tests 2026-04-20 11:35:47 +00:00
Anshuman Pandey 398ba79e7e feat: ces and csat questions (#7688)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-20 15:33:26 +04:00
Tiago Farto 5127de9de0 chore: revert CI action 2026-04-20 11:11:09 +00:00
Tiago Farto 2bf7788a1b Merge branch 'epic/bullmq' into feat/survey-scheduling 2026-04-20 11:07:33 +00:00
Tiago Farto ee8122778b chore: address PR comments 2026-04-20 10:43:32 +00:00
Tiago Farto 8aaa7ed9c0 chore: build fix 2026-04-20 10:00:06 +00:00
Johannes bc7c8c5715 remove environment ID andenv references 2026-04-20 11:40:33 +02:00
Dhruwang Jariwala ab1ea7a5ce fix: remove legacy API rewrites from next.config.mjs (#7764)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-20 13:38:30 +04:00
Tiago Farto 4f749355e0 chore: fix coverage test 2026-04-20 09:14:06 +00:00
Tiago Farto 18b60ddd35 chore: fix build 2026-04-20 08:52:59 +00:00
Tiago Farto 87f1b01c7a chore: fix broken tests 2026-04-20 08:40:44 +00:00
Tiago Farto 851ea0deb2 chore: fix broken lock 2026-04-20 08:32:45 +00:00
pandeymangg 9abbbfdd35 chore: merge with main 2026-04-20 13:07:36 +05:30
Johannes cefc2bdf60 fix: show oversized upload error when mime type is missing (#7757)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-20 07:00:41 +00:00
dependabot[bot] 78473bf3d0 chore(deps): bump the npm_and_yarn group across 12 directories with 4 updates (#7680)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-04-20 06:59:52 +00:00
Johannes 15403c6a92 fix: add accessible dialog title to project limit modal (#7769)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:45:21 +00:00
Johannes 35b98863a4 feat: auto-fill safe attribute key from label (#7771)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:44:10 +00:00
Anshuman Pandey 65f5968fb1 fix: fixes sentry ref issue (#7776) 2026-04-20 06:29:44 +00:00
Bhagya Amarasinghe 2dfea4d72f fix: prevent split offline responses on restore (#7767) 2026-04-20 06:05:13 +00:00
Johannes 990c0eee31 refined UX 2026-04-19 16:05:29 +02:00
Tiago Farto 07f16b8a43 chore: fix build 2026-04-17 23:23:51 +00:00
Tiago Farto bf556b0608 chore: fix linting 2026-04-17 22:26:57 +00:00
Tiago Farto 8b0766a46e chore: bix fuild 2026-04-17 22:16:17 +00:00
Tiago Farto 1f995d6e25 chore: build fix 2026-04-17 20:05:11 +00:00
Tiago Farto 975a4d57f8 chore: fix build 2026-04-17 19:50:23 +00:00
Tiago Farto 69bd576fc5 chore: fix build 2026-04-17 16:37:22 +00:00
Tiago Farto a2e4a3bbd7 chore: fix build 2026-04-17 16:27:18 +00:00
Tiago Farto 281f854332 chore: address PR comments 2026-04-17 15:36:12 +00:00
Tiago Farto 24496774a5 chore: fix build 2026-04-17 14:57:55 +00:00
Tiago Farto aeaf3215b4 chore: fix 2026-04-17 14:51:51 +00:00
Tiago Farto f4c5162590 Merge epic/bullmq into feat/survey-scheduling 2026-04-17 14:47:05 +00:00
Tiago Farto dedb7389f0 Merge origin/epic/v5 into epic/bullmq 2026-04-17 14:33:21 +00:00
Dhruwang Jariwala ff77118932 fix: response tag UI issues in response modal (#7765) 2026-04-17 11:59:59 +00:00
Tiago Farto 7aed1b84de chore: translations, fixes 2026-04-17 11:59:17 +00:00
Johannes 79a773432a feat: extend auto-progress to single-select question types (#7725) 2026-04-17 10:17:00 +00:00
Niels Kaspers d53869f1df fix: fix duplicate block and misleading subheader in trial conversion template (#7560)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 10:01:54 +00:00
Balázs Úr fc9ddb2b0d fix: mark Identify Customer Goals survey as translatable (#7566)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 09:53:15 +00:00
Bhagya Amarasinghe 6fcb6863bd feat: migrate survey overview to v3 APIs (#7741) 2026-04-17 09:45:12 +00:00
Johannes b1cee91ad9 fix: redirect active project and organization selections (#7724)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 09:33:12 +00:00
Bhagya Amarasinghe 9d2e988c59 feat: remove app rate limits for Envoy-covered routes (#7714)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-17 12:43:22 +04:00
Dhruwang Jariwala 60bd5cbeff fix: prevent environment ID leak in API error responses (#7753)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:38:32 +00:00
Dhruwang Jariwala b6a3a15379 fix: make other option input field mandatory when sole selection (#7751)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:06:00 +00:00
Johannes c68f214eff fix: keep sidebar switcher icons round with long labels (#7756)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 08:04:10 +00:00
Harsh Bhat c90ee84483 chore: Add survey to formbricks docs (#7746) 2026-04-16 12:13:55 +00:00
Dhruwang Jariwala dc1ee72594 chore: translation management revamp (scope 1) (#7733)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-16 11:18:48 +00:00
Dhruwang Jariwala 924132287e fix: connect rating/NPS scale labels to label styling settings (#7738)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 10:59:59 +00:00
Dhruwang Jariwala e6f347aa07 fix: remove dark: variant classes from survey-ui to prevent host page style leakage (#7747) 2026-04-16 05:50:46 +00:00
Dhruwang Jariwala 367bc23dd4 fix: prevent offline replay from dropping survey blocks after completion (#7743) 2026-04-15 19:59:15 +00:00
XHamzaX a1a11b2bb8 fix: prevent OIDC button text overlap with 'last used' indicator (#7731)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-15 09:42:20 +00:00
Tiago 31d2ea7444 chore: Move Response Pipeline to BullMQ (#7695) 2026-04-15 10:12:41 +03:00
Marius 0653c6a59f fix: strip @layer properties block to prevent host page CSS pollution (#7685)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 06:58:35 +00:00
Anshuman Pandey b6d793e109 fix: fixes unique constraint error with singleUseId and surveyId (#7737) 2026-04-15 06:50:20 +00:00
Tiago Farto d361c334d3 chore: fixed management snapshot gap 2026-04-13 14:28:31 +03:00
Tiago Farto a4d808b479 chore: build fix 2026-04-13 13:10:33 +03:00
Tiago Farto 18ae1748d3 chore: address PR comments 2026-04-13 12:50:21 +03:00
Tiago Farto 3404e0c494 chore: fix string date convertion error 2026-04-09 17:14:14 +03:00
Tiago Farto 83499ae552 chore: fix build 2026-04-09 15:14:44 +03:00
Tiago Farto 2ac0c1eb07 chore: refactor 2026-04-09 15:04:31 +03:00
Tiago Farto 54ede3015e chore: fix build 2026-04-09 14:09:46 +03:00
Tiago Farto 1b4f05a062 chore: fix linting issue 2026-04-09 13:59:34 +03:00
Tiago Farto 197dbf5aa6 chore: address pr comments 2026-04-09 13:45:32 +03:00
Tiago 7ca52a7a93 feat: Introduce BullMQ setup to Formbricks (#7684) 2026-04-09 11:47:58 +03:00
Tiago Farto 4a48839d17 Merge branch 'feat/background_workers_v1' into chore/response-to-bullmq 2026-04-09 11:43:30 +03:00
Tiago Farto 92bd9bdac7 chore: address PR comments 2026-04-09 11:26:12 +03:00
Tiago Farto ad4b6f8b8c chore: addressing additional PR comments 2026-04-09 10:39:01 +03:00
Tiago Farto 8de5079db3 chore: lint fix 2026-04-09 10:07:29 +03:00
Tiago Farto a60206dd44 chore: fix sonarqube warnings 2026-04-09 09:59:09 +03:00
Tiago Farto d66abdcdaf chore: refactoring 2026-04-09 09:26:38 +03:00
Tiago Farto 03fa41a911 fix: tighten v2 response validation details typing 2026-04-08 23:23:37 +03:00
Tiago Farto cab438e474 chore: refactor 2026-04-08 21:47:15 +03:00
Tiago Farto a6dfe78c81 fix: restore response pipeline safety guards 2026-04-08 20:47:47 +03:00
Tiago Farto e4d96f4379 fix: resolve jobs runtime type import for web build 2026-04-08 17:16:17 +03:00
Tiago Farto 581a66b4a9 chore: fix problems 2026-04-08 17:00:36 +03:00
Tiago Farto 5cf0c15812 chore: response to bullmq 2026-04-08 14:43:50 +03:00
Tiago Farto ebaa2d363c chore: fix flaky test 2026-04-08 10:25:48 +03:00
Tiago Farto 597ea40b75 chore: fix linting issues 2026-04-08 10:16:24 +03:00
Tiago Farto 3c39dcc2de chore: increased test coverage 2026-04-08 09:51:58 +03:00
Tiago Farto e8df1dbb35 chore: fix sonarqube warning 2026-04-07 22:15:10 +03:00
Tiago Farto 84987ce557 chore: linter fixes 2026-04-07 21:42:23 +03:00
Tiago Farto 784ed855d7 chore: additional tests; address PR comments 2026-04-07 21:14:52 +03:00
Tiago Farto 5a17d4144d fix: normalize storage result typing for web build 2026-04-07 19:07:15 +03:00
Tiago Farto 65c9db86c6 fix: separate storage type exports and imports 2026-04-07 18:04:27 +03:00
Tiago Farto bc94d34d1e fix: narrow storage route results by property 2026-04-07 17:41:13 +03:00
Tiago Farto 22be60a0ba fix: align storage type exports for web build 2026-04-07 17:18:53 +03:00
Tiago Farto a384963863 fix: type storage delete wrappers 2026-04-07 16:34:51 +03:00
Tiago Farto c067ae73bb fix: narrow storage delete result in route 2026-04-07 16:25:36 +03:00
Tiago Farto dc78a30cbe fix: repair pnpm lockfile for BullMQ branch 2026-04-07 16:13:17 +03:00
Tiago Farto 9c9ae8a3a2 test: fix env test on v5 branch 2026-04-07 16:01:21 +03:00
Tiago Farto 29a08151aa chore: addressed PR concerns 2026-04-07 15:59:20 +03:00
Tiago Farto f42a8822a9 chore: background workers trough bullMQ 2026-04-07 15:56:12 +03:00
641 changed files with 54349 additions and 13799 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
+37
View File
@@ -32,6 +32,24 @@ CRON_SECRET=
# Set the minimum log level(debug, info, warn, error, fatal)
LOG_LEVEL=info
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
# BULLMQ_WORKER_ENABLED=1
# Set to 1 on web/API pods that only enqueue jobs while a separate BullMQ worker deployment consumes them.
# BULLMQ_EXTERNAL_WORKER_ENABLED=0
# Number of BullMQ worker instances started per Formbricks server process.
# BULLMQ_WORKER_COUNT=1
# Number of concurrent jobs each BullMQ worker can process.
# BULLMQ_WORKER_CONCURRENCY=1
# Survey publish/close scheduling is configured with public build-time env vars because the editor UI
# also needs to render the selected execution time and timezone.
# NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR=0
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE=0
##############
# DATABASE #
##############
@@ -278,5 +296,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 local Postgres service by default in docker-compose.dev.yml.
# Override these only if your Hub DB runs on a different host.
# CUBEJS_DB_HOST=postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=postgres
# CUBEJS_DB_USER=postgres
# CUBEJS_DB_PASS=postgres
#
# Alternative (when not on same Docker network): host.docker.internal and port 5433
# Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here
-2
View File
@@ -64,7 +64,5 @@ packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdat
.cursorrules
i18n.cache
stats.html
api-testing-plan.config.json
reports/api-testing-plan/
# next-agents-md
.next-docs/
+1
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
-262
View File
@@ -1,262 +0,0 @@
# API Regression Testing Plan (Environment -> Workspace Migration)
## Goal
Validate that API key authorization and returned data stay behaviorally identical after moving from environment-centric access to workspace-centric access.
This plan is based on current route/auth logic in:
- `apps/web/app/api/v1/**`
- `apps/web/modules/api/v2/**`
- `apps/web/app/api/v3/**`
- `apps/web/modules/ee/contacts/api/**`
## API Keys Under Test
| Key Alias | Actual Key | Intended Scope |
|---|---|---|
| `K1_W1_DEV_MANAGE` | `fbk_k65Cpf1ZHJTJjKyhixQ7OzWHTTTUIC5u2UQun1PMhcg` | Workspace 1 / Dev / `manage` |
| `K2_W1_PROD_READ` | `fbk_f829m6TovrkojaPC40WBFJ7DVBVPs7iz0T0yWf9i_zo` | Workspace 1 / Prod / `read` |
| `K3_W2_DEV_MANAGE` | `fbk_0_bfuUpv6p9mbZe3o5EWnMnziw3gBUnrdMK0YG0aAKs` | Workspace 2 / Dev / `manage` |
| `K4_W2_PROD_WRITE` | `fbk_-6ozHplGDklxK6qTivfYEbv_gRSsWx_ZPonc4eaGcYo` | Workspace 2 / Prod / `write` |
## Permission Model To Regress
Environment permission mapping from `hasPermission(...)`:
- `GET` -> requires `read` (or higher)
- `POST` / `PUT` / `PATCH` -> requires `write` (or `manage`)
- `DELETE` -> requires `manage`
Expected by key:
- `K1` and `K3` (`manage`): allowed for all methods in their own environment.
- `K4` (`write`): allowed for `GET/POST/PUT/PATCH` in own environment; denied `DELETE`.
- `K2` (`read`): allowed for `GET` in own environment only; denied write/delete methods.
## Test Data Setup (Required Before Execution)
Create stable fixtures per environment/workspace so every endpoint can be validated deterministically:
- Workspace 1 Dev (`W1_DEV_ENV_ID`)
- Workspace 1 Prod (`W1_PROD_ENV_ID`)
- Workspace 2 Dev (`W2_DEV_ENV_ID`)
- Workspace 2 Prod (`W2_PROD_ENV_ID`)
For **each** environment, seed at least:
- 1 survey (`link` type), with one completed and one unfinished response
- 1 webhook tied to that survey
- 1 action class
- 1 contact + contact attributes + custom contact attribute key (if EE contacts enabled)
- 1 segment containing that contact (for contact-link endpoints)
- 1 storage-upload-capable target (for signed URL endpoint)
Also keep fixture IDs for every resource so both positive and negative access tests can reference exact resources.
---
## Expected Data Visibility Rules (Applies Everywhere)
1. **Collection endpoints** must return only resources belonging to environments listed in the API key permissions.
2. **Resource-by-id endpoints** must:
- return success only when resource environment matches key scope and method permission,
- otherwise return authorization failure (`401`/`403` depending on endpoint family).
3. **Cross-workspace leakage is never allowed**: no key should ever read/modify resource in another workspace/env.
4. **v3 workspace endpoints** must map workspace -> environment consistently; authorization outcome should match legacy env permission semantics.
---
## Endpoint Matrix: v1
### Auth and Profile
| Endpoint | Method | Scope Source | Expected By Key |
|---|---|---|---|
| `/api/v1/auth` | `GET` | API key itself | All 4 keys: `200`; returns API key + linked env permissions |
| `/api/v1/management/me` | `GET` | API key itself | `200` only if key has exactly one env permission (these 4 should); payload must match that env/project |
### Surveys
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|---|---|---|---|---|---|---|
| `/api/v1/management/surveys` | `GET` | all key envs | 200, only W1 Dev surveys | 200, only W1 Prod surveys | 200, only W2 Dev surveys | 200, only W2 Prod surveys |
| `/api/v1/management/surveys` | `POST` | `body.environmentId` | 200 on W1 Dev, deny others | 401 | 200 on W2 Dev, deny others | 200 on W2 Prod, deny others |
| `/api/v1/management/surveys/{surveyId}` | `GET` | survey env | allow own env | allow own env | allow own env | allow own env |
| `/api/v1/management/surveys/{surveyId}` | `PUT` | survey env | allow own env | 401 | allow own env | allow own env |
| `/api/v1/management/surveys/{surveyId}` | `DELETE` | survey env | allow own env | 401 | allow own env | 401 |
| `/api/v1/management/surveys/{surveyId}/singleUseIds` | `GET` | survey env | allow own env link survey | allow own env link survey | allow own env link survey | allow own env link survey |
### Responses
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|---|---|---|---|---|---|---|
| `/api/v1/management/responses` | `GET` | query `surveyId` env, or all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
| `/api/v1/management/responses` | `POST` | `body.environmentId` + survey env match | allow own env | 401 | allow own env | allow own env |
| `/api/v1/management/responses/{responseId}` | `GET` | response->survey env | allow own env | allow own env | allow own env | allow own env |
| `/api/v1/management/responses/{responseId}` | `PUT` | response->survey env | allow own env | 401 | allow own env | allow own env |
| `/api/v1/management/responses/{responseId}` | `DELETE` | response->survey env | allow own env | 401 | allow own env | 401 |
### Action Classes
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|---|---|---|---|---|---|---|
| `/api/v1/management/action-classes` | `GET` | all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
| `/api/v1/management/action-classes` | `POST` | `body.environmentId` | allow own env | 401 | allow own env | allow own env |
| `/api/v1/management/action-classes/{id}` | `GET` | actionClass env | allow own env | allow own env | allow own env | allow own env |
| `/api/v1/management/action-classes/{id}` | `PUT` | actionClass env | allow own env | 401 | allow own env | allow own env |
| `/api/v1/management/action-classes/{id}` | `DELETE` | actionClass env | allow own env | 401 | allow own env | 401 |
### Webhooks
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|---|---|---|---|---|---|---|
| `/api/v1/webhooks` | `GET` | all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
| `/api/v1/webhooks` | `POST` | `body.environmentId` | allow own env | 401 | allow own env | allow own env |
| `/api/v1/webhooks/{id}` | `GET` | webhook env | allow own env | allow own env | allow own env | allow own env |
| `/api/v1/webhooks/{id}` | `DELETE` | webhook env | allow own env | 401 | allow own env | 401 |
### Storage Signed Upload
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|---|---|---|---|---|---|---|
| `/api/v1/management/storage` | `POST` | `body.environmentId` | allow own env | 401 | allow own env | allow own env |
### EE Contacts (if enabled)
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|---|---|---|---|---|---|---|
| `/api/v1/management/contacts` | `GET` | all key envs | 200 scoped (or 403 if EE off) | same | same | same |
| `/api/v1/management/contacts/{id}` | `GET` | contact env | allow own env | allow own env | allow own env | allow own env |
| `/api/v1/management/contacts/{id}` | `DELETE` | contact env | allow own env | 401 | allow own env | 401 |
| `/api/v1/management/contact-attributes` | `GET` | all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
| `/api/v1/management/contact-attribute-keys` | `GET` | all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
| `/api/v1/management/contact-attribute-keys` | `POST` | `body.environmentId` | allow own env | 401 | allow own env | allow own env |
| `/api/v1/management/contact-attribute-keys/{id}` | `GET` | key env | allow own env | allow own env | allow own env | allow own env |
| `/api/v1/management/contact-attribute-keys/{id}` | `PUT` | key env | allow own env | 401 | allow own env | allow own env |
| `/api/v1/management/contact-attribute-keys/{id}` | `DELETE` | key env | allow own env | 401 | allow own env | 401 |
---
## Endpoint Matrix: v2
### Global / Org-Level
| Endpoint | Method | Expected |
|---|---|---|
| `/api/v2/health` | `GET` | 200 if healthy (no API key required) |
| `/api/v2/roles` | `GET` | All 4 keys should get 200 with same role list |
| `/api/v2/me` | `GET` | 200 only when key has organization access (`read`/`write`), otherwise 401 |
> Note: v2 org endpoints (`/api/v2/organizations/{organizationId}/teams`, `/project-teams`, `/users`) require `organizationAccess` and matching organizationId param. If your four keys only have env permissions, expected result is unauthorized for all methods.
### Responses
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|---|---|---|---|---|---|---|
| `/api/v2/management/responses` | `GET` | all key envs (plus query filters) | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
| `/api/v2/management/responses` | `POST` | survey -> env | allow own env (201) | 401 | allow own env (201) | allow own env (201) |
| `/api/v2/management/responses/{id}` | `GET` | response -> env | allow own env | allow own env | allow own env | allow own env |
| `/api/v2/management/responses/{id}` | `PUT` | response -> env | allow own env | 401 | allow own env | allow own env |
| `/api/v2/management/responses/{id}` | `DELETE` | response -> env | allow own env | 401 | allow own env | 401 |
### Webhooks
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|---|---|---|---|---|---|---|
| `/api/v2/management/webhooks` | `GET` | all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
| `/api/v2/management/webhooks` | `POST` | `body.environmentId` | allow own env (201) | 403 | allow own env (201) | allow own env (201) |
| `/api/v2/management/webhooks/{id}` | `GET` | webhook env | allow own env | allow own env | allow own env | allow own env |
| `/api/v2/management/webhooks/{id}` | `PUT` | webhook env | allow own env | 401 | allow own env | allow own env |
| `/api/v2/management/webhooks/{id}` | `DELETE` | webhook env | allow own env | 401 | allow own env | 401 |
### Contact Attribute Keys
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|---|---|---|---|---|---|---|
| `/api/v2/management/contact-attribute-keys` | `GET` | query env or all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
| `/api/v2/management/contact-attribute-keys` | `POST` | `body.environmentId` | allow own env (201) | 403 | allow own env (201) | allow own env (201) |
| `/api/v2/management/contact-attribute-keys/{id}` | `GET` | key env | allow own env | allow own env | allow own env | allow own env |
| `/api/v2/management/contact-attribute-keys/{id}` | `PUT` | key env | allow own env | 401 | allow own env | allow own env |
| `/api/v2/management/contact-attribute-keys/{id}` | `DELETE` | key env | allow own env | 401 | allow own env | 401 |
### Survey Contact Links (EE Contacts + link surveys)
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|---|---|---|---|---|---|---|
| `/api/v2/management/surveys/{surveyId}/contact-links/contacts/{contactId}` | `GET` | survey -> env | allow own env | allow own env | allow own env | allow own env |
| `/api/v2/management/surveys/{surveyId}/contact-links/segments/{segmentId}` | `GET` | survey -> env | allow own env | allow own env | allow own env | allow own env |
### EE Contacts (if enabled)
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|---|---|---|---|---|---|---|
| `/api/v2/management/contacts` | `POST` | `body.environmentId` | allow own env (201) | 403 | allow own env (201) | allow own env (201) |
| `/api/v2/management/contacts/bulk` | `PUT` | `body.environmentId` | allow own env (200/207) | 403 | allow own env (200/207) | allow own env (200/207) |
---
## Endpoint Matrix: v3
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|---|---|---|---|---|---|---|
| `/api/v3/surveys?workspaceId={ws}` | `GET` | workspaceId -> environment via resolver | 200 for W1 Dev workspace; 403 for others | 200 for W1 Prod workspace; 403 for others | 200 for W2 Dev workspace; 403 for others | 200 for W2 Prod workspace; 403 for others |
Regression-critical checks for v3:
- No workspace can be accessed via key from another workspace.
- Returned survey list/count/cursor must match legacy env-scoped survey visibility.
- Response always includes `X-Request-Id`.
---
## Concrete Regression Test Cases (Execute For Every Endpoint Family)
For each endpoint/method:
1. **Positive Same-Scope**
- Use key with sufficient permission against its own env/workspace resource.
- Assert success status and payload shape.
2. **Negative Cross-Workspace**
- Use same key against a resource in another workspace.
- Assert auth failure and no data leakage.
3. **Negative Insufficient Method Permission**
- `K2` (`read`) on write endpoints -> deny.
- `K4` (`write`) on delete endpoints -> deny.
4. **Collection Leakage Test**
- Call list endpoint with each key.
- Assert every returned row belongs only to that keys allowed env(s).
5. **ID Enumeration Safety**
- Try valid IDs from unauthorized envs.
- Assert deny response, no foreign resource details.
---
## Suggested Execution Order
1. v1 auth/me
2. v1 survey/response/action-class/webhook/storage
3. v1 EE contacts
4. v2 global + management
5. v2 org endpoints (if organizationAccess is configured on these keys)
6. v3 surveys by workspace
Run each step for all 4 keys before proceeding.
---
## Pass Criteria
Migration is regression-safe when all are true:
- Authorization decisions are unchanged (same allow/deny per key/method/resource scope).
- Returned datasets are unchanged in scope (no missing own-scope data, no foreign-scope leakage).
- Error classes/statuses remain consistent per API family (v1/v2/v3).
- v3 workspace mapping yields equivalent visibility to legacy environment mapping.
---
## Optional Automation Harness (Recommended)
Implement an automated matrix runner (Postman/Newman or Playwright API tests) with:
- Inputs: `baseUrl`, 4 API keys, fixture IDs by env/workspace
- For each test: endpoint, method, key alias, expected status, expected env/workspace ownership assertions
- Final report grouped by endpoint family and key alias
This makes repeated migration verification deterministic and CI-friendly.
-75
View File
@@ -1,75 +0,0 @@
{
"baseUrl": "http://localhost:3000",
"expectations": {
"v1ManagementMeStatusByKey": {
"K1_W1_DEV_MANAGE": 200,
"K2_W1_PROD_READ": 200,
"K3_W2_DEV_MANAGE": 200,
"K4_W2_PROD_WRITE": 200
},
"v2MeStatusByKey": {
"K1_W1_DEV_MANAGE": 401,
"K2_W1_PROD_READ": 401,
"K3_W2_DEV_MANAGE": 401,
"K4_W2_PROD_WRITE": 401
}
},
"features": {
"eeContacts": true,
"storage": true
},
"planPath": "API Testing plan.md",
"scopes": {
"w1Dev": {
"actionClassId": "cmo1hb042002b0jmfh5sw4oln",
"contactAttributeKeyId": "cmo1gowa900070jmfyfzowueh",
"contactId": "cmo1w5vtb000306zndlqg833n",
"environmentId": "cmo1gowa900060jmfab7c10ue",
"responseId": "cmo1h6rgl00280jmfskrznu1t",
"segmentId": "cmo1w5vtl000706znnv0nnai5",
"surveyId": "cmo1h667t00250jmfliszkm7s",
"webhookId": "cmo1w5vt2000106zn3th11yg6",
"workspaceId": "cmo1gow9300050jmf2zkbd649"
},
"w1Prod": {
"actionClassId": "cmo1hcc6j002c0jmfw97ney4c",
"contactAttributeKeyId": "cmo1gowb4000d0jmfukh4lm6g",
"contactId": "cmo1h2xs7001t0jmfat14ofz1",
"environmentId": "cmo1gowb4000c0jmfxjntbf9h",
"responseId": "cmo1w5w1j000906znfn2waklw",
"segmentId": "cmo1h5hbt00240jmfp4ddwwto",
"surveyId": "cmo1gphm3000i0jmfz7crtakg",
"webhookId": "cmo1gzg8i000n0jmfx35fkxr1",
"workspaceId": "cmo1gow9300050jmf2zkbd649"
},
"w2Dev": {
"actionClassId": null,
"contactAttributeKeyId": "cmo1hu1dy003c0jmfiaraklmk",
"contactId": null,
"environmentId": "cmo1hu1dx003b0jmfe7qy50to",
"responseId": null,
"segmentId": null,
"surveyId": null,
"webhookId": null,
"workspaceId": "cmo1hu1dh003a0jmf0y80rcak"
},
"w2Prod": {
"actionClassId": null,
"contactAttributeKeyId": "cmo1hu1e9003i0jmfciblxunp",
"contactId": null,
"environmentId": "cmo1hu1e9003h0jmfmsqk8mwo",
"responseId": "cmo1hugl2003p0jmf52na95w2",
"segmentId": null,
"surveyId": "cmo1hu7ih003n0jmfz0vgep9d",
"webhookId": null,
"workspaceId": "cmo1hu1dh003a0jmf0y80rcak"
}
},
"timeouts": {
"healthPollIntervalMs": 2000,
"maxConsecutiveNetworkFailures": 3,
"requestMs": 15000,
"startupWaitMs": 120000,
"stepMs": 30000
}
}
+1 -1
View File
@@ -23,7 +23,7 @@
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.1",
"vite": "7.3.2",
"@storybook/addon-docs": "10.2.17"
}
}
-7
View File
@@ -1,7 +0,0 @@
import { redirect } from "next/navigation";
const Page = () => {
return redirect("/");
};
export default Page;
@@ -0,0 +1,8 @@
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
const ChartsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
return <ChartsListPage workspaceId={workspaceId} />;
};
export default ChartsPage;
@@ -0,0 +1,7 @@
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
const Page = (props: { params: Promise<{ workspaceId: string; dashboardId: string }> }) => {
return <DashboardDetailPage params={props.params} />;
};
export default Page;
@@ -0,0 +1,8 @@
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
const DashboardsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
return <DashboardsListPage workspaceId={workspaceId} />;
};
export default DashboardsPage;
@@ -0,0 +1,3 @@
import { AnalysisListLoading } from "@/modules/ee/analysis/loading";
export default AnalysisListLoading;
@@ -0,0 +1 @@
export { WorkspaceSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
@@ -23,9 +23,13 @@ import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
import { getOrganizationsByUserId } from "./lib/organization";
import { getWorkspacesByUserId } from "./lib/workspace";
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
feedbackRecordDirectoryId: ZId.optional(),
});
const ZCreateWorkspaceAction = z.object({
organizationId: ZId,
data: ZWorkspaceUpdateInput,
data: ZCreateWorkspaceInput,
});
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
@@ -40,7 +44,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
access: [
{
data: parsedInput.data,
schema: ZWorkspaceUpdateInput,
schema: ZCreateWorkspaceInput,
type: "organization",
roles: ["owner", "manager"],
},
@@ -2,6 +2,7 @@
import {
ArrowUpRightIcon,
BarChart3Icon,
Building2Icon,
ChevronRightIcon,
Cog,
@@ -9,6 +10,7 @@ import {
Loader2,
LogOutIcon,
MessageCircle,
MessageSquareTextIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
@@ -144,44 +146,77 @@ export const MainNavigation = ({
}
}, [pathname]);
const mainNavigation = useMemo(
const mainNavigationSections = useMemo(
() => [
{
name: t("common.surveys"),
href: `/workspaces/${workspace.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
isHidden: false,
disabled: isMembershipPending || isBilling,
id: "ask",
name: "Ask",
items: [
{
name: t("common.surveys"),
href: `/workspaces/${workspace.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
href: `/workspaces/${workspace.id}/contacts`,
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
disabled: isMembershipPending || isBilling,
},
],
},
{
href: `/workspaces/${workspace.id}/contacts`,
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
disabled: isMembershipPending || isBilling,
},
{
name: t("common.configuration"),
href: `/workspaces/${workspace.id}/general`,
icon: Cog,
isActive:
pathname?.includes("/general") ||
pathname?.includes("/look") ||
pathname?.includes("/app-connection") ||
pathname?.includes("/integrations") ||
pathname?.includes("/teams") ||
pathname?.includes("/languages") ||
pathname?.includes("/tags"),
disabled: isMembershipPending || isBilling,
id: "unify-feedback",
name: t("workspace.unify.unify_feedback"),
items: [
{
name: t("workspace.unify.feedback_records"),
href: `/workspaces/${workspace.id}/unify/feedback-records`,
icon: MessageSquareTextIcon,
isActive: pathname?.includes("/unify/feedback-records"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
name: t("common.dashboards"),
href: `/workspaces/${workspace.id}/dashboards`,
icon: BarChart3Icon,
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
],
},
],
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
const configurationNavigationItem = useMemo(
() => ({
name: t("common.configuration"),
href: `/workspaces/${workspace.id}/general`,
icon: Cog,
isActive:
pathname?.includes("/general") ||
pathname?.includes("/look") ||
pathname?.includes("/app-connection") ||
pathname?.includes("/feedback-sources") ||
pathname?.includes("/integrations") ||
pathname?.includes("/teams") ||
pathname?.includes("/languages") ||
pathname?.includes("/tags"),
disabled: isMembershipPending || isBilling,
}),
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
const dropdownNavigation = [
{
label: t("common.account"),
@@ -240,6 +275,11 @@ export const MainNavigation = ({
label: t("common.website_and_app_connection"),
href: `/workspaces/${workspace.id}/app-connection`,
},
{
id: "feedback-sources",
label: t("workspace.unify.feedback_sources"),
href: `/workspaces/${workspace.id}/feedback-sources`,
},
{
id: "integrations",
label: t("common.integrations"),
@@ -297,6 +337,15 @@ export const MainNavigation = ({
href: `/workspaces/${workspace.id}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.title"),
href: `/workspaces/${workspace.id}/settings/feedback-record-directories`,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
];
const loadWorkspaces = useCallback(async () => {
@@ -413,16 +462,22 @@ export const MainNavigation = ({
: `/workspaces/${workspace.id}/surveys/`;
const handleWorkspaceChange = (workspaceId: string) => {
if (workspaceId === workspace.id) return;
const targetPath =
workspaceId === workspace.id ? `/workspaces/${workspace.id}/surveys` : `/workspaces/${workspaceId}/`;
startTransition(() => {
router.push(`/workspaces/${workspaceId}/`);
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === organization.id) return;
const targetPath =
organizationId === organization.id
? `/workspaces/${workspace.id}/settings/general`
: `/organizations/${organizationId}/`;
startTransition(() => {
router.push(`/organizations/${organizationId}/`);
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
});
};
@@ -479,7 +534,7 @@ export const MainNavigation = ({
);
const switcherIconClasses =
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialWorkspacesLoading =
isWorkspaceDropdownOpen && !hasInitializedWorkspaces && !workspaceLoadError;
@@ -521,23 +576,50 @@ export const MainNavigation = ({
</div>
{/* Main Nav Switch */}
<ul>
{mainNavigation.map(
(item) =>
!item.isHidden && (
<NavigationLink
key={item.name}
href={item.href}
isActive={item.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={item.disabled}
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
linkText={item.name}>
<item.icon strokeWidth={1.5} />
</NavigationLink>
)
)}
<ul className="space-y-2">
{mainNavigationSections.map((section) => (
<li key={section.id}>
{!isCollapsed && !isTextVisible && (
<p className="px-4 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
{section.name}
</p>
)}
<ul>
{section.items.map(
(item) =>
!item.isHidden && (
<NavigationLink
key={item.name}
href={item.href}
isActive={item.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={item.disabled}
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
linkText={item.name}>
<item.icon strokeWidth={1.5} />
</NavigationLink>
)
)}
</ul>
</li>
))}
<li className={cn("mt-2 border-t border-slate-100 pt-2", isCollapsed && "border-t-0 pt-0")}>
<NavigationLink
href={configurationNavigationItem.href}
isActive={configurationNavigationItem.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={configurationNavigationItem.disabled}
disabledMessage={
configurationNavigationItem.disabled ? disabledNavigationMessage : undefined
}
linkText={configurationNavigationItem.name}>
<configurationNavigationItem.icon strokeWidth={1.5} />
</NavigationLink>
</li>
</ul>
</div>
@@ -117,8 +117,12 @@ export const OrganizationBreadcrumb = ({
const workspaceBasePath = `/workspaces/${workspace?.id}`;
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === currentOrganizationId) return;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentWorkspaceId) {
router.push(`/workspaces/${currentWorkspaceId}/settings/general`);
return;
}
router.push(`/organizations/${organizationId}/`);
});
};
@@ -144,12 +148,6 @@ export const OrganizationBreadcrumb = ({
label: t("common.members_and_teams"),
href: `${workspaceBasePath}/settings/teams`,
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.nav_label"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
hidden: isMember,
},
{
id: "api-keys",
label: t("common.api_keys"),
@@ -181,6 +179,15 @@ export const OrganizationBreadcrumb = ({
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.title"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
];
return (
@@ -118,6 +118,11 @@ export const WorkspaceBreadcrumb = ({
label: t("common.website_and_app_connection"),
href: `${workspaceBasePath}/app-connection`,
},
{
id: "feedback-sources",
label: t("workspace.unify.feedback_sources"),
href: `${workspaceBasePath}/feedback-sources`,
},
{
id: "integrations",
label: t("common.integrations"),
@@ -138,6 +143,11 @@ export const WorkspaceBreadcrumb = ({
label: t("common.tags"),
href: `${workspaceBasePath}/tags`,
},
{
id: "unify",
label: t("common.unify"),
href: `${workspaceBasePath}/workspace/unify`,
},
];
const areWorkspaceSettingsDisabled = isMembershipPending || isBilling;
@@ -153,9 +163,13 @@ export const WorkspaceBreadcrumb = ({
}
const handleWorkspaceChange = (workspaceId: string) => {
if (workspaceId === currentWorkspaceId) return;
const targetPath =
workspaceId === currentWorkspaceId
? `/workspaces/${currentWorkspaceId}/surveys`
: `/workspaces/${workspaceId}/`;
startTransition(() => {
router.push(`/workspaces/${workspaceId}/`);
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
@@ -8,7 +8,7 @@ import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/type
import { Badge } from "@/modules/ui/components/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled">;
type TFeatureDefinition = {
key: TPublicLicenseFeatureKey;
@@ -61,6 +61,16 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
labelKey: t("workspace.settings.enterprise.license_feature_spam_protection"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
},
{
key: "aiSmartTools",
labelKey: t("workspace.settings.general.ai_smart_tools_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "aiDataAnalysis",
labelKey: t("workspace.settings.general.ai_data_analysis_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "auditLogs",
labelKey: t("workspace.settings.enterprise.license_feature_audit_logs"),
@@ -6,24 +6,32 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { updateOrganizationAISettingsAction } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/general/actions";
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { type ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
interface AISettingsToggleProps {
organization: TOrganization;
membershipRole?: TOrganizationRole;
isInstanceAIConfigured: boolean;
hasAIPermission: boolean;
isFormbricksCloud: boolean;
}
export const AISettingsToggle = ({
organization,
membershipRole,
isInstanceAIConfigured,
hasAIPermission,
isFormbricksCloud,
}: Readonly<AISettingsToggleProps>) => {
const { workspace } = useWorkspace();
const workspaceBasePath = `/workspaces/${workspace?.id}`;
const [loadingField, setLoadingField] = useState<string | null>(null);
const { t } = useTranslation();
const router = useRouter();
@@ -78,6 +86,32 @@ export const AISettingsToggle = ({
}
};
const upgradeButtons: [ModalButton, ModalButton] = [
{
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `${workspaceBasePath}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `${workspaceBasePath}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
];
if (!hasAIPermission) {
return (
<UpgradePrompt
title={t("workspace.settings.general.unlock_ai_features_with_a_higher_plan")}
description={t("workspace.settings.general.unlock_ai_features_description")}
buttons={upgradeButtons}
feature="ai_features"
/>
);
}
return (
<div className="space-y-4">
{showInstanceConfigWarning && (
@@ -3,7 +3,12 @@ import { isInstanceAIConfigured } from "@/lib/ai/service";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import {
getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled,
getIsMultiOrgEnabled,
getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -27,8 +32,14 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null;
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
getIsAIDataAnalysisEnabled(organization.id),
]);
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -64,6 +75,8 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
organization={organization}
membershipRole={currentUserMembership?.role}
isInstanceAIConfigured={isInstanceAIConfigured()}
hasAIPermission={hasAIPermission}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
</SettingsCard>
<EmailCustomizationSettings
@@ -21,6 +21,7 @@ export const SettingsCard = ({
beta,
className,
buttonInfo,
cta,
}: {
title: string;
description: string;
@@ -30,6 +31,7 @@ export const SettingsCard = ({
beta?: boolean;
className?: string;
buttonInfo?: ButtonInfo;
cta?: React.ReactNode;
}) => {
const { t } = useTranslation();
return (
@@ -52,11 +54,12 @@ export const SettingsCard = ({
{description}
</Small>
</div>
{buttonInfo && (
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
{buttonInfo?.text}
</Button>
)}
{cta ??
(buttonInfo && (
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
{buttonInfo?.text}
</Button>
))}
</div>
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
</div>
@@ -0,0 +1,51 @@
"use client";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryCes } from "@formbricks/types/surveys/types";
import { RatingLikeSummary } from "./RatingLikeSummary";
interface CESSummaryProps {
elementSummary: TSurveyElementSummaryCes;
survey: TSurvey;
setFilter: (
elementId: string,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
}
export const CESSummary = ({ elementSummary, survey, setFilter }: CESSummaryProps) => {
const { t } = useTranslation();
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary.element.scale]);
return (
<RatingLikeSummary
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.effort_score")}: {elementSummary.average.toFixed(2)} /{" "}
{elementSummary.element.range}
</div>
</div>
</div>
}
/>
);
};
@@ -0,0 +1,72 @@
"use client";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryCsat } from "@formbricks/types/surveys/types";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { RatingLikeSummary } from "./RatingLikeSummary";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
interface CSATSummaryProps {
elementSummary: TSurveyElementSummaryCsat;
survey: TSurvey;
setFilter: (
elementId: string,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
}
export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryProps) => {
const { t } = useTranslation();
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary.element.scale]);
return (
<RatingLikeSummary
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
</div>
</div>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
<div>
{t("workspace.surveys.summary.csat_satisfied", {
percentage: elementSummary.csat.satisfiedPercentage,
})}
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("workspace.surveys.summary.csat_satisfied_tooltip", {
percentage: elementSummary.csat.satisfiedPercentage,
})}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
/>
);
};
@@ -8,7 +8,7 @@ import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryNps } from "@formbricks/types/surveys/types";
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { convertFloatToNDecimal } from "../lib/utils";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
@@ -39,6 +39,7 @@ const calculateNPSOpacity = (rating: number): number => {
export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const promotersPercentage = convertFloatToNDecimal(elementSummary.promoters.percentage, 2);
const applyFilter = (group: string) => {
const filters = {
@@ -81,13 +82,23 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
elementSummary={elementSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
<div>
{t("workspace.surveys.summary.promoters")}:{" "}
{convertFloatToNDecimal(elementSummary.promoters.percentage, 2)}%
</div>
</div>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
<div>
{t("workspace.surveys.summary.promoters")}: {promotersPercentage}%
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("workspace.surveys.summary.nps_promoters_tooltip", {
percentage: promotersPercentage,
})}
</TooltipContent>
</Tooltip>
</TooltipProvider>
}
/>
@@ -0,0 +1,214 @@
"use client";
import { BarChart, BarChartHorizontal } from "lucide-react";
import { type JSX, useState } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyElementSummaryCes,
TSurveyElementSummaryCsat,
TSurveyElementSummaryRating,
} from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { RatingScaleLegend } from "./RatingScaleLegend";
type RatingLikeElementSummary =
| TSurveyElementSummaryCes
| TSurveyElementSummaryCsat
| TSurveyElementSummaryRating;
interface RatingLikeSummaryProps {
elementSummary: RatingLikeElementSummary;
survey: TSurvey;
setFilter: (
elementId: string,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
additionalInfo: JSX.Element;
}
export const RatingLikeSummary = ({
elementSummary,
survey,
setFilter,
additionalInfo,
}: RatingLikeSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} additionalInfo={additionalInfo} />
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("workspace.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("workspace.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{elementSummary.responseCount === 0 ? (
<>
<EmptyState text={t("workspace.surveys.summary.no_responses_found")} variant="simple" />
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
) : (
<>
<TooltipProvider delayDuration={200}>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
const range = elementSummary.element.range;
const opacity = 0.3 + (result.rating / range) * 0.7;
const isFirst = index === 0;
const isLast = index === elementSummary.choices.length - 1;
return (
<ClickableBarSegment
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
}}
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
);
})}
</div>
</TooltipProvider>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
return (
<div
key={result.rating}
className="flex flex-col items-center justify-center py-2"
style={{
width: `${result.percentage}%`,
borderRight:
index < elementSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={false}
variant="aggregated"
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{elementSummary.choices.map((result) => (
<div key={result.rating}>
<button
className="w-full cursor-pointer hover:opacity-80"
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={elementSummary.element.isColorCodingEnabled}
variant="individual"
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: result.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">
<div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.dismissed.count })}
</p>
</div>
</div>
</div>
)}
</div>
);
};
@@ -1,21 +1,12 @@
"use client";
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryRating } from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { RatingScaleLegend } from "./RatingScaleLegend";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
import { RatingLikeSummary } from "./RatingLikeSummary";
interface RatingSummaryProps {
elementSummary: TSurveyElementSummaryRating;
@@ -31,196 +22,29 @@ interface RatingSummaryProps {
export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary]);
}, [elementSummary.element.scale]);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader
elementSummary={elementSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
</div>
</div>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
<div>
CSAT: {elementSummary.csat.satisfiedPercentage}% {t("workspace.surveys.summary.satisfied")}
</div>
</div>
</div>
}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("workspace.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("workspace.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{elementSummary.responseCount === 0 ? (
<>
<EmptyState text={t("workspace.surveys.summary.no_responses_found")} variant="simple" />
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
) : (
<>
<TooltipProvider delayDuration={200}>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
const range = elementSummary.element.range;
const opacity = 0.3 + (result.rating / range) * 0.8;
const isFirst = index === 0;
const isLast = index === elementSummary.choices.length - 1;
return (
<ClickableBarSegment
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
}}
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
);
})}
</div>
</TooltipProvider>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
return (
<div
key={result.rating}
className="flex flex-col items-center justify-center py-2"
style={{
width: `${result.percentage}%`,
borderRight:
index < elementSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={false}
variant="aggregated"
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{elementSummary.choices.map((result) => (
<div key={result.rating}>
<button
className="w-full cursor-pointer hover:opacity-80"
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={elementSummary.element.isColorCodingEnabled}
variant="individual"
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: result.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">
<div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.dismissed.count })}
</p>
<RatingLikeSummary
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
</div>
</div>
</div>
)}
</div>
}
/>
);
};
@@ -22,8 +22,23 @@ export const SuccessMessage = ({ survey }: SummaryMetadataProps) => {
const appSetupCompleted = workspace.appSetupCompleted;
useEffect(() => {
const newSurveyParam = searchParams?.get("success");
if (newSurveyParam && survey && workspace) {
const publishSuccessParam = searchParams.get("success");
const scheduledSuccessParam = searchParams.get("scheduled");
if (scheduledSuccessParam) {
toast.success(t("workspace.surveys.summary.survey_scheduled_successfully"), {
id: "survey-schedule-success-toast",
duration: 5000,
position: "bottom-right",
});
const url = new URL(globalThis.location.href);
url.searchParams.delete("scheduled");
globalThis.history.replaceState({}, "", url.toString());
return;
}
if (publishSuccessParam) {
setConfetti(true);
toast.success(
isAppSurvey && !appSetupCompleted
@@ -38,16 +53,16 @@ export const SuccessMessage = ({ survey }: SummaryMetadataProps) => {
);
// Remove success param from url
const url = new URL(window.location.href);
const url = new URL(globalThis.location.href);
url.searchParams.delete("success");
if (survey.type === "link") {
// Add share param to url to open share embed modal
url.searchParams.set("share", "true");
}
window.history.replaceState({}, "", url.toString());
globalThis.history.replaceState({}, "", url.toString());
}
}, [workspace, isAppSurvey, searchParams, survey, appSetupCompleted, t]);
}, [appSetupCompleted, isAppSurvey, searchParams, survey.type, t]);
return <>{confetti && <Confetti />}</>;
};
@@ -13,6 +13,8 @@ import {
SelectedFilterValue,
useResponseFilter,
} from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { CESSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CESSummary";
import { CSATSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CSATSummary";
import { CTASummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
import { CalSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
import { ConsentSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
@@ -156,6 +158,26 @@ export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryL
/>
);
}
if (elementSummary.type === TSurveyElementTypeEnum.CSAT) {
return (
<CSATSummary
key={elementSummary.element.id}
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
/>
);
}
if (elementSummary.type === TSurveyElementTypeEnum.CES) {
return (
<CESSummary
key={elementSummary.element.id}
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
/>
);
}
if (elementSummary.type === TSurveyElementTypeEnum.Consent) {
return (
<ConsentSummary
@@ -4578,3 +4578,611 @@ describe("Cal question type tests", () => {
expect(summary[0].skipped.count).toBe(1); // Counted as skipped
});
});
describe("CSAT question type tests", () => {
test("getElementSummary correctly processes CSAT question with valid responses", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: true,
scale: "smiley",
range: 5,
lowerLabel: { default: "Very unsatisfied" },
upperLabel: { default: "Very satisfied" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-2",
data: { "csat-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-3",
data: { "csat-q1": 2 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-4",
data: { "csat-q1": 1 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "csat-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
expect(summary[0].responseCount).toBe(4);
// Average = (5 + 4 + 2 + 1) / 4 = 3.0
expect(summary[0].average).toBe(3);
// CSAT: satisfied = ratings 4 + 5 = 2 out of 4
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(50);
// Verify choice distribution
const rating5 = summary[0].choices.find((c: any) => c.rating === 5);
expect(rating5.count).toBe(1);
expect(rating5.percentage).toBe(25);
const rating4 = summary[0].choices.find((c: any) => c.rating === 4);
expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25);
const rating2 = summary[0].choices.find((c: any) => c.rating === 2);
expect(rating2.count).toBe(1);
expect(rating2.percentage).toBe(25);
const rating1 = summary[0].choices.find((c: any) => c.rating === 1);
expect(rating1.count).toBe(1);
expect(rating1.percentage).toBe(25);
expect(summary[0].dismissed.count).toBe(0);
});
test("getElementSummary handles CSAT question with dismissed responses", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: false,
scale: "smiley",
range: 5,
lowerLabel: { default: "Very unsatisfied" },
upperLabel: { default: "Very satisfied" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "csat-q1": 3 },
finished: true,
},
{
id: "response-2",
data: {},
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "csat-q1": 2 },
finished: true,
},
{
id: "response-3",
data: {},
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "csat-q1": 4 },
finished: true,
},
] as any;
const dropOff = [
{ elementId: "csat-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
expect(summary[0].responseCount).toBe(1);
expect(summary[0].average).toBe(5);
expect(summary[0].dismissed.count).toBe(2);
expect(summary[0].csat.satisfiedCount).toBe(1);
expect(summary[0].csat.satisfiedPercentage).toBe(100);
});
test("getElementSummary handles CSAT question with no valid responses", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: true,
scale: "smiley",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "other-q": "value" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "csat-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
expect(summary[0].responseCount).toBe(0);
expect(summary[0].average).toBe(0);
expect(summary[0].csat.satisfiedCount).toBe(0);
expect(summary[0].csat.satisfiedPercentage).toBe(0);
expect(summary[0].choices.every((c: any) => c.count === 0)).toBe(true);
});
test("getElementSummary CSAT correctly identifies satisfied ratings (4 and 5 only)", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: true,
scale: "smiley",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
// 3 satisfied (4,5,5), 2 not satisfied (1,3)
const responses = [
{
id: "r1",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "csat-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "csat-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r4",
data: { "csat-q1": 1 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r5",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "csat-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
// satisfied = ratings 4 and 5 = 3 out of 5
expect(summary[0].csat.satisfiedCount).toBe(3);
expect(summary[0].csat.satisfiedPercentage).toBe(60);
});
});
describe("CES question type tests", () => {
test("getElementSummary correctly processes CES question with valid responses (range 5)", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Very difficult" },
upperLabel: { default: "Very easy" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "ces-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-2",
data: { "ces-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-3",
data: { "ces-q1": 2 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-4",
data: { "ces-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "ces-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(4);
// CES = average = (5 + 4 + 2 + 3) / 4 = 3.5
expect(summary[0].average).toBe(3.5);
// Verify choice distribution
const rating5 = summary[0].choices.find((c: any) => c.rating === 5);
expect(rating5.count).toBe(1);
expect(rating5.percentage).toBe(25);
const rating4 = summary[0].choices.find((c: any) => c.rating === 4);
expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25);
const rating3 = summary[0].choices.find((c: any) => c.rating === 3);
expect(rating3.count).toBe(1);
expect(rating3.percentage).toBe(25);
const rating2 = summary[0].choices.find((c: any) => c.rating === 2);
expect(rating2.count).toBe(1);
expect(rating2.percentage).toBe(25);
expect(summary[0].dismissed.count).toBe(0);
// CES has no csat field
expect(summary[0].csat).toBeUndefined();
});
test("getElementSummary correctly processes CES question with range 7", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: true,
scale: "number",
range: 7,
lowerLabel: { default: "Very difficult" },
upperLabel: { default: "Very easy" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "r1",
data: { "ces-q1": 7 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "ces-q1": 6 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "ces-q1": 1 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "ces-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(3);
// CES average = (7 + 6 + 1) / 3 = 4.67
expect(summary[0].average).toBe(4.67);
// Verify 7 choices exist (range 7)
expect(summary[0].choices).toHaveLength(7);
});
test("getElementSummary handles CES question with dismissed responses", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: false,
scale: "number",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "ces-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "ces-q1": 5 },
finished: true,
},
{
id: "response-2",
data: {},
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "ces-q1": 2 },
finished: true,
},
] as any;
const dropOff = [
{ elementId: "ces-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(1);
expect(summary[0].average).toBe(3);
expect(summary[0].dismissed.count).toBe(1);
});
test("getElementSummary handles CES question with no valid responses", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: true,
scale: "number",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "other-q": "value" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "ces-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(0);
expect(summary[0].average).toBe(0);
expect(summary[0].choices.every((c: any) => c.count === 0)).toBe(true);
});
});
@@ -25,7 +25,6 @@ import {
TSurveyElementSummaryOpenText,
TSurveyElementSummaryPictureSelection,
TSurveyElementSummaryRanking,
TSurveyElementSummaryRating,
TSurveyLanguage,
TSurveySummary,
} from "@formbricks/types/surveys/types";
@@ -272,6 +271,49 @@ const checkForI18n = (
return responseData[id];
};
const computeNumericScaleStats = (
elementId: string,
range: number,
responses: TSurveySummaryResponse[]
): {
choices: { rating: number; count: number; percentage: number }[];
choiceCountMap: Record<number, number>;
totalResponseCount: number;
totalRating: number;
dismissed: number;
average: number;
} => {
const choiceCountMap: Record<number, number> = {};
for (let i = 1; i <= range; i++) {
choiceCountMap[i] = 0;
}
let totalResponseCount = 0;
let totalRating = 0;
let dismissed = 0;
responses.forEach((response) => {
const answer = response.data[elementId];
if (typeof answer === "number") {
totalResponseCount++;
choiceCountMap[answer]++;
totalRating += answer;
} else if (response.ttc && response.ttc[elementId] > 0) {
dismissed++;
}
});
const choices = Object.entries(choiceCountMap).map(([label, count]) => ({
rating: Number.parseInt(label),
count,
percentage: totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
}));
const average = totalResponseCount > 0 ? convertFloatTo2Decimal(totalRating / totalResponseCount) : 0;
return { choices, choiceCountMap, totalResponseCount, totalRating, dismissed, average };
};
export const getElementSummary = async (
survey: TSurvey,
elements: TSurveyElement[],
@@ -472,72 +514,16 @@ export const getElementSummary = async (
break;
}
case TSurveyElementTypeEnum.Rating: {
let values: TSurveyElementSummaryRating["choices"] = [];
const choiceCountMap: Record<number, number> = {};
const range = element.range;
for (let i = 1; i <= range; i++) {
choiceCountMap[i] = 0;
}
let totalResponseCount = 0;
let totalRating = 0;
let dismissed = 0;
responses.forEach((response) => {
const answer = response.data[element.id];
if (typeof answer === "number") {
totalResponseCount++;
choiceCountMap[answer]++;
totalRating += answer;
} else if (response.ttc && response.ttc[element.id] > 0) {
dismissed++;
}
});
Object.entries(choiceCountMap).forEach(([label, count]) => {
values.push({
rating: Number.parseInt(label),
count,
percentage:
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
});
});
// Calculate CSAT based on range
let satisfiedCount = 0;
if (range === 3) {
satisfiedCount = choiceCountMap[3] || 0;
} else if (range === 4) {
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
} else if (range === 5) {
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
} else if (range === 6) {
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
} else if (range === 7) {
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
} else if (range === 10) {
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
}
const satisfiedPercentage =
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
const stats = computeNumericScaleStats(element.id, element.range, responses);
summary.push({
type: element.type,
element,
average: convertFloatTo2Decimal(totalRating / totalResponseCount) || 0,
responseCount: totalResponseCount,
choices: values,
dismissed: {
count: dismissed,
},
csat: {
satisfiedCount,
satisfiedPercentage,
},
average: stats.average,
responseCount: stats.totalResponseCount,
choices: stats.choices,
dismissed: { count: stats.dismissed },
});
values = [];
break;
}
case TSurveyElementTypeEnum.NPS: {
@@ -612,6 +598,40 @@ export const getElementSummary = async (
});
break;
}
case TSurveyElementTypeEnum.CSAT: {
const stats = computeNumericScaleStats(element.id, element.range, responses);
// CSAT: top 2 ratings out of 5 are "satisfied"
const satisfiedCount = (stats.choiceCountMap[4] || 0) + (stats.choiceCountMap[5] || 0);
const satisfiedPercentage =
stats.totalResponseCount > 0
? convertFloatTo2Decimal((satisfiedCount / stats.totalResponseCount) * 100)
: 0;
summary.push({
type: element.type,
element,
average: stats.average,
responseCount: stats.totalResponseCount,
choices: stats.choices,
dismissed: { count: stats.dismissed },
csat: { satisfiedCount, satisfiedPercentage },
});
break;
}
case TSurveyElementTypeEnum.CES: {
const stats = computeNumericScaleStats(element.id, element.range, responses);
summary.push({
type: element.type,
element,
average: stats.average,
responseCount: stats.totalResponseCount,
choices: stats.choices,
dismissed: { count: stats.dismissed },
});
break;
}
case TSurveyElementTypeEnum.CTA: {
// Only calculate summary for CTA elements with external buttons (CTR tracking is only meaningful for external links)
if (!element.buttonExternal) {
@@ -287,7 +287,7 @@ export const ElementFilterComboBox = ({
</div>
{open && (
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
<div className="absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none animate-in">
<CommandList className="max-h-52">
<CommandInput
value={searchQuery}
@@ -232,7 +232,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
</div>
{open && (
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
<div className="absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none animate-in">
<CommandList className="max-h-[600px]">
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => (
@@ -4,7 +4,6 @@ import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
@@ -22,18 +21,19 @@ interface SurveyStatusDropdownProps {
}
export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: SurveyStatusDropdownProps) => {
const { workspace } = useWorkspaceContext();
const { t } = useTranslation();
const router = useRouter();
const isScheduled = survey.status === "paused" && survey.publishOn !== null;
const handleStatusChange = async (status: TSurvey["status"]) => {
const updateSurveyActionResponse = await updateSurveyAction({ ...survey, status });
if (updateSurveyActionResponse?.data) {
const resultingStatus = updateSurveyActionResponse.data.status;
const { publishOn, status: resultingStatus } = updateSurveyActionResponse.data;
const isResultScheduled = resultingStatus === "paused" && publishOn !== null;
const statusToToastMessage: Partial<Record<TSurvey["status"], string>> = {
inProgress: t("common.survey_live"),
paused: t("common.survey_paused"),
paused: isResultScheduled ? t("common.survey_scheduled") : t("common.survey_paused"),
completed: t("common.survey_completed"),
};
@@ -68,12 +68,10 @@ export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: Survey
<SelectTrigger className="w-[170px] bg-white md:w-[200px]">
<SelectValue>
<div className="flex items-center">
{(survey.type === "link" || workspace.appSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
<SurveyStatusIndicator status={survey.status} isScheduled={isScheduled} />
<span className="ml-2 text-sm text-slate-700">
{survey.status === "inProgress" && t("common.in_progress")}
{survey.status === "paused" && t("common.paused")}
{survey.status === "paused" && (isScheduled ? t("common.scheduled") : t("common.paused"))}
{survey.status === "completed" && t("common.completed")}
</span>
</div>
@@ -88,8 +86,8 @@ export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: Survey
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="paused">
<div className="flex w-full items-center justify-center gap-2">
<SurveyStatusIndicator status={"paused"} />
{t("common.paused")}
<SurveyStatusIndicator status={"paused"} isScheduled={isScheduled} />
{isScheduled ? t("common.scheduled") : t("common.paused")}
</div>
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="completed">
@@ -0,0 +1,8 @@
import { type ReactNode } from "react";
import { SurveysQueryClientProvider } from "./query-client-provider";
const SurveysLayout = ({ children }: { children: ReactNode }) => {
return <SurveysQueryClientProvider>{children}</SurveysQueryClientProvider>;
};
export default SurveysLayout;
@@ -0,0 +1,10 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
export const SurveysQueryClientProvider = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
@@ -0,0 +1,42 @@
"use client";
import { useTranslation } from "react-i18next";
import { Badge } from "@/modules/ui/components/badge";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface UnifyConfigNavigationProps {
workspaceId: string;
activeId?: string;
loading?: boolean;
}
export const UnifyConfigNavigation = ({
workspaceId,
activeId: activeIdProp,
loading,
}: UnifyConfigNavigationProps) => {
const { t } = useTranslation();
const baseHref = `/workspaces/${workspaceId}/unify`;
const activeId = activeIdProp ?? "feedback-records";
const navigation = [
{
id: "feedback-records",
label: t("workspace.unify.feedback_records"),
href: `${baseHref}/feedback-records`,
},
{
id: "topics-subtopics",
label: (
<span className="inline-flex items-center gap-2">
{t("workspace.unify.topics_and_subtopics")}
<Badge text={t("common.soon")} type="gray" size="tiny" />
</span>
),
disabled: true,
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
};
@@ -0,0 +1,197 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { createFeedbackRecord, retrieveFeedbackRecord, updateFeedbackRecord } from "@/modules/hub/service";
import type { FeedbackRecordCreateParams, FeedbackRecordUpdateParams } from "@/modules/hub/types";
const ZFeedbackRecordId = z.uuid();
const ZFeedbackRecordFieldType = z.enum([
"text",
"categorical",
"nps",
"csat",
"ces",
"rating",
"number",
"boolean",
"date",
]);
const ZFeedbackRecordMetadata = z.record(z.string(), z.unknown());
const ZFeedbackRecordCreateInput = z.object({
submission_id: z.string().min(1),
tenant_id: ZId,
source_type: z.string().min(1),
field_id: z.string().min(1),
field_type: ZFeedbackRecordFieldType,
collected_at: z.iso.datetime().optional(),
source_id: z.string().optional().nullable(),
source_name: z.string().optional().nullable(),
field_label: z.string().optional().nullable(),
field_group_id: z.string().optional(),
field_group_label: z.string().optional().nullable(),
value_text: z.string().optional().nullable(),
value_number: z.number().optional(),
value_boolean: z.boolean().optional(),
value_date: z.iso.datetime().optional(),
metadata: ZFeedbackRecordMetadata.optional(),
language: z.string().optional(),
user_identifier: z.string().optional(),
});
const ZFeedbackRecordUpdateInput = z
.object({
value_text: z.string().optional().nullable(),
value_number: z.number().optional().nullable(),
value_boolean: z.boolean().optional().nullable(),
value_date: z.iso.datetime().optional().nullable(),
language: z.string().optional().nullable(),
metadata: ZFeedbackRecordMetadata.optional(),
user_identifier: z.string().optional().nullable(),
})
.refine(
(value) => Object.values(value).some((entry) => entry !== undefined),
"At least one field must be provided for update"
);
const ZRetrieveFeedbackRecordAction = z.object({
workspaceId: ZId,
recordId: ZFeedbackRecordId,
});
const ZCreateFeedbackRecordAction = z.object({
workspaceId: ZId,
recordInput: ZFeedbackRecordCreateInput,
});
const ZUpdateFeedbackRecordAction = z.object({
workspaceId: ZId,
recordId: ZFeedbackRecordId,
updateInput: ZFeedbackRecordUpdateInput,
});
const ensureAccess = async (
userId: string,
workspaceId: string,
minPermission: "read" | "readWrite"
): Promise<void> => {
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
await checkAuthorizationUpdated({
userId,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "workspaceTeam",
minPermission,
workspaceId,
},
],
});
};
const getWorkspaceDirectoryIds = async (workspaceId: string): Promise<Set<string>> => {
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
return new Set(directories.map((directory) => directory.id));
};
const assertWorkspaceDirectoryAccess = (directoryIds: Set<string>, tenantId: string): void => {
if (!directoryIds.has(tenantId)) {
throw new AuthorizationError("Invalid feedback record directory for this workspace");
}
};
export const retrieveFeedbackRecordAction = authenticatedActionClient
.inputSchema(ZRetrieveFeedbackRecordAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZRetrieveFeedbackRecordAction>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "read");
const recordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!recordResult.data || recordResult.error) {
throw new Error(recordResult.error?.message || "Failed to retrieve feedback record");
}
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, recordResult.data.tenant_id);
return recordResult.data;
}
);
export const createFeedbackRecordAction = authenticatedActionClient
.inputSchema(ZCreateFeedbackRecordAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateFeedbackRecordAction>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, parsedInput.recordInput.tenant_id);
const createResult = await createFeedbackRecord(
parsedInput.recordInput as unknown as FeedbackRecordCreateParams
);
if (!createResult.data || createResult.error) {
throw new Error(createResult.error?.message || "Failed to create feedback record");
}
return createResult.data;
}
);
export const updateFeedbackRecordAction = authenticatedActionClient
.inputSchema(ZUpdateFeedbackRecordAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateFeedbackRecordAction>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!currentRecordResult.data || currentRecordResult.error) {
throw new Error(currentRecordResult.error?.message || "Failed to retrieve feedback record");
}
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
const updatePayload = Object.fromEntries(
Object.entries(parsedInput.updateInput).filter(([, value]) => value !== undefined)
) as unknown as FeedbackRecordUpdateParams;
const updateResult = await updateFeedbackRecord(parsedInput.recordId, updatePayload);
if (!updateResult.data || updateResult.error) {
throw new Error(updateResult.error?.message || "Failed to update feedback record");
}
return updateResult.data;
}
);
@@ -0,0 +1,988 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { v7 as uuidv7 } from "uuid";
import { z } from "zod";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { FeedbackRecordData } from "@/modules/hub/types";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/modules/ui/components/sheet";
import { Switch } from "@/modules/ui/components/switch";
import {
createFeedbackRecordAction,
retrieveFeedbackRecordAction,
updateFeedbackRecordAction,
} from "./actions";
type FeedbackRecordDrawerMode = "create" | "edit";
interface FeedbackRecordFormDrawerProps {
mode: FeedbackRecordDrawerMode;
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string;
directories: { id: string; name: string }[];
canWrite: boolean;
recordId?: string;
onSuccess: () => Promise<void> | void;
}
const FIELD_TYPE_OPTIONS = [
"text",
"categorical",
"nps",
"csat",
"ces",
"rating",
"number",
"boolean",
"date",
] as const;
const SOURCE_TYPE_PRESET_OPTIONS = [
"survey",
"review",
"feedback_form",
"support",
"social",
"interview",
"usability_test",
"nps_campaign",
] as const;
const SOURCE_TYPE_CUSTOM_VALUE = "__custom__";
const ZMetadataEntry = z.object({
key: z.string().trim().min(1),
value: z.string(),
});
const ZFeedbackRecordFormValues = z.object({
id: z.string().optional(),
tenant_id: z.string().min(1),
submission_id: z.string().min(1),
collected_at: z.string().min(1),
created_at: z.string().optional(),
updated_at: z.string().optional(),
source_type: z.string().min(1),
source_id: z.string().optional(),
source_name: z.string().optional(),
field_id: z.string().min(1),
field_label: z.string().optional(),
field_type: z.enum(FIELD_TYPE_OPTIONS),
field_group_id: z.string().optional(),
field_group_label: z.string().optional(),
value_text: z.string().optional(),
value_number: z.string().optional(),
value_boolean: z.boolean().optional(),
value_date: z.string().optional(),
language: z.string().optional(),
user_identifier: z.string().optional(),
metadataEntries: z.array(ZMetadataEntry),
});
type TFeedbackRecordFormValues = z.infer<typeof ZFeedbackRecordFormValues>;
const getValueFieldByType = (
fieldType: TFeedbackRecordFormValues["field_type"]
): "value_text" | "value_number" | "value_boolean" | "value_date" => {
switch (fieldType) {
case "boolean":
return "value_boolean";
case "date":
return "value_date";
case "nps":
case "csat":
case "ces":
case "rating":
case "number":
return "value_number";
default:
return "value_text";
}
};
const toLocalDateTimeInput = (isoDate: string): string => {
const date = new Date(isoDate);
if (!Number.isFinite(date.getTime())) {
return "";
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
const toISOOrUndefined = (dateTimeValue: string | undefined): string | undefined => {
if (!dateTimeValue) {
return undefined;
}
const parsed = new Date(dateTimeValue);
if (!Number.isFinite(parsed.getTime())) {
return undefined;
}
return parsed.toISOString();
};
const getCreateDefaults = (directories: { id: string; name: string }[]): TFeedbackRecordFormValues => {
const now = new Date();
const defaultDirectoryId = directories[0]?.id ?? "";
return {
id: "",
tenant_id: defaultDirectoryId,
submission_id: uuidv7(),
collected_at: toLocalDateTimeInput(now.toISOString()),
created_at: "",
updated_at: "",
source_type: "survey",
source_id: "",
source_name: "",
field_id: "",
field_label: "",
field_type: "text",
field_group_id: "",
field_group_label: "",
value_text: "",
value_number: "",
value_boolean: undefined,
value_date: "",
language: "",
user_identifier: "",
metadataEntries: [],
};
};
const mapRecordToValues = (record: FeedbackRecordData): TFeedbackRecordFormValues => {
const metadataEntries = Object.entries(record.metadata ?? {})
.filter(([, value]) => typeof value === "string")
.map(([key, value]) => ({
key,
value: value as string,
}));
return {
id: record.id,
tenant_id: record.tenant_id,
submission_id: record.submission_id,
collected_at: toLocalDateTimeInput(record.collected_at),
created_at: record.created_at ? toLocalDateTimeInput(record.created_at) : "",
updated_at: record.updated_at ? toLocalDateTimeInput(record.updated_at) : "",
source_type: record.source_type,
source_id: record.source_id ?? "",
source_name: record.source_name ?? "",
field_id: record.field_id,
field_label: record.field_label ?? "",
field_type: record.field_type,
field_group_id: record.field_group_id ?? "",
field_group_label: record.field_group_label ?? "",
value_text: record.value_text ?? "",
value_number: record.value_number == null ? "" : String(record.value_number),
value_boolean: record.value_boolean,
value_date: record.value_date ? toLocalDateTimeInput(record.value_date) : "",
language: record.language ?? "",
user_identifier: record.user_identifier ?? "",
metadataEntries,
};
};
const getReadOnlyMetadataEntries = (record: FeedbackRecordData): { key: string; value: string }[] => {
return Object.entries(record.metadata ?? {})
.filter(([, value]) => typeof value !== "string")
.map(([key, value]) => ({
key,
value: JSON.stringify(value),
}));
};
const parseNumberValue = (value: string): number | null => {
if (value.trim() === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const formatSourceType = (sourceType: string, t: (key: string) => string): string => {
switch (sourceType) {
case "formbricks":
case "formbricks_survey":
return t("workspace.unify.formbricks_surveys");
case "csv":
return t("workspace.unify.csv_import");
default:
return sourceType;
}
};
export const FeedbackRecordFormDrawer = ({
mode,
open,
onOpenChange,
workspaceId,
directories,
canWrite,
recordId,
onSuccess,
}: Readonly<FeedbackRecordFormDrawerProps>) => {
const { t } = useTranslation();
const [record, setRecord] = useState<FeedbackRecordData | null>(null);
const [isLoadingRecord, setIsLoadingRecord] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]);
const form = useForm<TFeedbackRecordFormValues>({
resolver: zodResolver(ZFeedbackRecordFormValues),
defaultValues,
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "metadataEntries",
});
const fieldType = form.watch("field_type");
const selectedValueField = getValueFieldByType(fieldType);
const isEditMode = mode === "edit";
const isReadOnly = isEditMode && !canWrite;
const [sourceTypeMode, setSourceTypeMode] = useState<string>("survey");
const [customSourceType, setCustomSourceType] = useState("");
const readOnlyMetadataEntries = useMemo(() => (record ? getReadOnlyMetadataEntries(record) : []), [record]);
const resetForCreate = useCallback(() => {
const nextDefaults = getCreateDefaults(directories);
form.reset(nextDefaults);
setRecord(null);
setSourceTypeMode(nextDefaults.source_type);
setCustomSourceType("");
}, [directories, form]);
useEffect(() => {
if (!open) return;
if (mode === "create") {
resetForCreate();
return;
}
if (!recordId) return;
const loadRecord = async () => {
setIsLoadingRecord(true);
const result = await retrieveFeedbackRecordAction({ workspaceId, recordId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result) || t("workspace.unify.failed_to_load_feedback_records"));
setIsLoadingRecord(false);
return;
}
setRecord(result.data);
form.reset(mapRecordToValues(result.data));
setSourceTypeMode(
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never)
? result.data.source_type
: SOURCE_TYPE_CUSTOM_VALUE
);
setCustomSourceType(
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never) ? "" : result.data.source_type
);
setIsLoadingRecord(false);
};
void loadRecord();
}, [form, mode, open, recordId, resetForCreate, t, workspaceId]);
const requestClose = useCallback(() => {
if (form.formState.isDirty && !isSubmitting) {
setIsDiscardDialogOpen(true);
return;
}
onOpenChange(false);
}, [form.formState.isDirty, isSubmitting, onOpenChange]);
const handleDrawerOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) {
onOpenChange(true);
return;
}
requestClose();
},
[onOpenChange, requestClose]
);
const handleDiscardChanges = () => {
setIsDiscardDialogOpen(false);
onOpenChange(false);
};
const setStrictValueValidationError = (message: string) => {
form.setError(selectedValueField, { type: "manual", message });
};
const handleSubmit = form.handleSubmit(async (values) => {
form.clearErrors();
if (mode === "create") {
const requiredValueError = t("workspace.unify.feedback_record_value_required");
if (selectedValueField === "value_text" && !values.value_text?.trim()) {
setStrictValueValidationError(requiredValueError);
return;
}
if (selectedValueField === "value_number" && parseNumberValue(values.value_number ?? "") == null) {
setStrictValueValidationError(requiredValueError);
return;
}
if (selectedValueField === "value_boolean" && values.value_boolean === undefined) {
setStrictValueValidationError(requiredValueError);
return;
}
if (selectedValueField === "value_date" && !toISOOrUndefined(values.value_date)) {
setStrictValueValidationError(requiredValueError);
return;
}
}
const metadata = Object.fromEntries(
values.metadataEntries
.map((entry) => ({
key: entry.key.trim(),
value: entry.value,
}))
.filter((entry) => entry.key.length > 0)
.map((entry) => [entry.key, entry.value])
);
setIsSubmitting(true);
try {
if (mode === "create") {
const sourceTypeValue =
sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE ? customSourceType.trim() : values.source_type;
const createResult = await createFeedbackRecordAction({
workspaceId,
recordInput: {
submission_id: values.submission_id.trim(),
tenant_id: values.tenant_id,
source_type: sourceTypeValue,
source_id: values.source_id?.trim() ? values.source_id.trim() : null,
source_name: values.source_name?.trim() ? values.source_name.trim() : null,
field_id: values.field_id.trim(),
field_label: values.field_label?.trim() ? values.field_label.trim() : null,
field_type: values.field_type,
field_group_id: values.field_group_id?.trim() || undefined,
field_group_label: values.field_group_label?.trim() ? values.field_group_label.trim() : null,
collected_at: toISOOrUndefined(values.collected_at),
value_text: selectedValueField === "value_text" ? (values.value_text ?? "") : null,
value_number:
selectedValueField === "value_number"
? (parseNumberValue(values.value_number ?? "") ?? undefined)
: undefined,
value_boolean: selectedValueField === "value_boolean" ? values.value_boolean : undefined,
value_date: selectedValueField === "value_date" ? toISOOrUndefined(values.value_date) : undefined,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
language: values.language?.trim() || undefined,
user_identifier: values.user_identifier?.trim() || undefined,
},
});
if (!createResult?.data) {
toast.error(getFormattedErrorMessage(createResult));
setIsSubmitting(false);
return;
}
} else {
if (!recordId) {
setIsSubmitting(false);
return;
}
const preservedMetadata = Object.fromEntries(
Object.entries(record?.metadata ?? {}).filter(([, value]) => typeof value !== "string")
);
const updatePayload: Record<string, unknown> = {
language: values.language?.trim() || null,
user_identifier: values.user_identifier?.trim() || null,
metadata: { ...preservedMetadata, ...metadata },
};
if (selectedValueField === "value_text") {
updatePayload.value_text = values.value_text?.trim() ?? "";
} else if (selectedValueField === "value_number") {
updatePayload.value_number = parseNumberValue(values.value_number ?? "");
} else if (selectedValueField === "value_boolean") {
updatePayload.value_boolean = values.value_boolean ?? null;
} else if (selectedValueField === "value_date") {
updatePayload.value_date = toISOOrUndefined(values.value_date) ?? null;
}
const updateResult = await updateFeedbackRecordAction({
workspaceId,
recordId,
updateInput: updatePayload as never,
});
if (!updateResult?.data) {
toast.error(getFormattedErrorMessage(updateResult));
setIsSubmitting(false);
return;
}
}
toast.success(
mode === "create"
? t("workspace.unify.feedback_record_created_successfully")
: t("workspace.unify.feedback_record_updated_successfully")
);
await onSuccess();
onOpenChange(false);
} finally {
setIsSubmitting(false);
}
});
const drawerTitle =
mode === "create"
? t("workspace.unify.add_feedback_record")
: t("workspace.unify.feedback_record_details");
const drawerDescription =
mode === "create"
? t("workspace.unify.add_feedback_record_description")
: t("workspace.unify.feedback_record_details_description");
const valueBooleanStatus = form.watch("value_boolean");
let valueBooleanLabel = t("common.not_set");
if (valueBooleanStatus === true) {
valueBooleanLabel = t("common.yes");
} else if (valueBooleanStatus === false) {
valueBooleanLabel = t("common.no");
}
return (
<>
<Sheet open={open} onOpenChange={handleDrawerOpenChange}>
<SheetContent className="w-full overflow-y-auto bg-white px-5 sm:max-w-2xl">
<SheetHeader>
<SheetTitle>{drawerTitle}</SheetTitle>
<SheetDescription>{drawerDescription}</SheetDescription>
</SheetHeader>
{isLoadingRecord ? (
<div className="py-8 text-sm text-slate-500">{t("common.loading")}</div>
) : (
<FormProvider {...form}>
<form className="space-y-4 py-4" onSubmit={handleSubmit}>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.id")}</FormLabel>
<FormControl>
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="tenant_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.feedback_record_directory")}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange} disabled>
<SelectTrigger>
<SelectValue
placeholder={t("workspace.unify.select_feedback_record_directory")}
/>
</SelectTrigger>
<SelectContent>
{directories.map((directory) => (
<SelectItem key={directory.id} value={directory.id}>
{directory.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="submission_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.submission_id")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={form.control}
name="collected_at"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.collected_at")}</FormLabel>
<FormControl>
<Input {...field} type="datetime-local" disabled={isEditMode || !canWrite} />
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="created_at"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.created_at")}</FormLabel>
<FormControl>
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="updated_at"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.updated_at")}</FormLabel>
<FormControl>
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
</FormControl>
</FormItem>
)}
/>
</div>
{isEditMode ? (
<FormField
control={form.control}
name="source_type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_type")}</FormLabel>
<FormControl>
<Input {...field} value={formatSourceType(field.value, t)} disabled />
</FormControl>
</FormItem>
)}
/>
) : (
<div className="space-y-2">
<FormLabel>{t("workspace.unify.source_type")}</FormLabel>
<Select
value={sourceTypeMode}
onValueChange={(value) => {
setSourceTypeMode(value);
if (value !== SOURCE_TYPE_CUSTOM_VALUE) {
form.setValue("source_type", value, { shouldDirty: true });
}
}}
disabled={!canWrite}>
<SelectTrigger>
<SelectValue placeholder={t("workspace.unify.select_feedback_record_source_type")} />
</SelectTrigger>
<SelectContent>
{SOURCE_TYPE_PRESET_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
<SelectItem value={SOURCE_TYPE_CUSTOM_VALUE}>
{t("workspace.unify.custom_source_type")}
</SelectItem>
</SelectContent>
</Select>
{sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE && (
<Input
value={customSourceType}
onChange={(event) => {
setCustomSourceType(event.target.value);
form.setValue("source_type", event.target.value, { shouldDirty: true });
}}
placeholder={t("workspace.unify.custom_source_type_placeholder")}
disabled={!canWrite}
/>
)}
<FormError>{form.formState.errors.source_type?.message}</FormError>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="source_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_id")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="source_name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="field_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_id")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={form.control}
name="field_label"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_label")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="field_type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_type")}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={(value) =>
field.onChange(value as TFeedbackRecordFormValues["field_type"])
}
disabled={isEditMode || !canWrite}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPE_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="field_group_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_group_id")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="field_group_label"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_group_label")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="value_text"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.value_text")}</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
disabled={selectedValueField !== "value_text" || isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="value_number"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.value_number")}</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
type="number"
step="any"
disabled={selectedValueField !== "value_number" || isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="value_date"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.value_date")}</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
type="datetime-local"
disabled={selectedValueField !== "value_date" || isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="value_boolean"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.value_boolean")}</FormLabel>
<FormControl>
<div className="flex items-center gap-3 rounded-md border border-slate-200 px-3 py-2">
<Switch
checked={field.value ?? false}
onCheckedChange={(checked) => field.onChange(checked)}
disabled={selectedValueField !== "value_boolean" || isReadOnly || !canWrite}
/>
<span className="text-sm text-slate-600">{valueBooleanLabel}</span>
</div>
</FormControl>
</FormItem>
)}
/>
<FormError>{form.formState.errors[selectedValueField]?.message}</FormError>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<Input {...field} disabled={!canWrite || isReadOnly} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="user_identifier"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.user_identifier")}</FormLabel>
<FormControl>
<Input {...field} disabled={!canWrite || isReadOnly} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>{t("workspace.unify.metadata")}</FormLabel>
{canWrite && !isReadOnly && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => append({ key: "", value: "" })}>
<PlusIcon className="h-4 w-4" />
{t("common.add")}
</Button>
)}
</div>
<div className="space-y-2">
{fields.map((field, index) => (
<div key={field.id} className="grid grid-cols-[1fr_1fr_auto] gap-2">
<FormField
control={form.control}
name={`metadataEntries.${index}.key`}
render={({ field: entryField }) => (
<FormItem>
<FormControl>
<Input
{...entryField}
placeholder={t("workspace.unify.metadata_key")}
disabled={isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`metadataEntries.${index}.value`}
render={({ field: entryField }) => (
<FormItem>
<FormControl>
<Input
{...entryField}
placeholder={t("workspace.unify.metadata_value")}
disabled={isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
{canWrite && !isReadOnly && (
<Button type="button" variant="outline" onClick={() => remove(index)}>
{t("common.delete")}
</Button>
)}
</div>
))}
</div>
{readOnlyMetadataEntries.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-slate-500">
{t("workspace.unify.metadata_read_only_entries")}
</p>
{readOnlyMetadataEntries.map((entry) => (
<div
key={entry.key}
className="grid grid-cols-2 gap-2 rounded-md bg-slate-50 p-2 text-xs">
<span className="font-medium text-slate-700">{entry.key}</span>
<span className="truncate text-slate-600" title={entry.value}>
{entry.value}
</span>
</div>
))}
</div>
)}
</div>
</form>
</FormProvider>
)}
<SheetFooter className="mt-2">
<Button variant="outline" onClick={requestClose} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
{canWrite && (
<Button onClick={handleSubmit} loading={isSubmitting} disabled={isLoadingRecord}>
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
<AlertDialog
open={isDiscardDialogOpen}
setOpen={setIsDiscardDialogOpen}
headerText={t("workspace.unify.discard_feedback_record_changes_title")}
mainText={t("workspace.unify.discard_feedback_record_changes_description")}
confirmBtnLabel={t("common.discard")}
declineBtnLabel={t("common.cancel")}
declineBtnVariant="outline"
onDecline={() => setIsDiscardDialogOpen(false)}
onConfirm={handleDiscardChanges}
/>
</>
);
};
@@ -0,0 +1,42 @@
"use client";
import { useTranslation } from "react-i18next";
import type { FeedbackRecordData } from "@/modules/hub/types";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UnifyConfigNavigation } from "../components/UnifyConfigNavigation";
import { FeedbackRecordsTable } from "./feedback-records-table";
interface FeedbackRecordsPageClientProps {
workspaceId: string;
initialRecords: FeedbackRecordData[];
frdMap: Record<string, string>;
csvSources: { id: string; name: string }[];
canWrite: boolean;
}
export function FeedbackRecordsPageClient({
workspaceId,
initialRecords,
frdMap,
csvSources,
canWrite,
}: Readonly<FeedbackRecordsPageClientProps>) {
const { t } = useTranslation();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.unify.feedback_records")}>
<UnifyConfigNavigation workspaceId={workspaceId} activeId="feedback-records" />
</PageHeader>
<FeedbackRecordsTable
workspaceId={workspaceId}
initialRecords={initialRecords}
frdMap={frdMap}
csvSources={csvSources}
canWrite={canWrite}
/>
</PageContentWrapper>
);
}
@@ -0,0 +1,392 @@
"use client";
import { TFunction } from "i18next";
import {
CalendarIcon,
ChevronDownIcon,
HashIcon,
MessageSquareTextIcon,
PlusIcon,
RefreshCwIcon,
ToggleLeftIcon,
TypeIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { FeedbackRecordData } from "@/modules/hub/types";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { CsvImportModal } from "../sources/components/csv-import-modal";
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
const RECORDS_PER_PAGE = 50;
const FIELD_TYPE_ICONS: Record<string, React.ReactNode> = {
text: <TypeIcon className="h-3.5 w-3.5" />,
categorical: <HashIcon className="h-3.5 w-3.5" />,
nps: <HashIcon className="h-3.5 w-3.5" />,
csat: <HashIcon className="h-3.5 w-3.5" />,
ces: <HashIcon className="h-3.5 w-3.5" />,
rating: <HashIcon className="h-3.5 w-3.5" />,
number: <HashIcon className="h-3.5 w-3.5" />,
boolean: <ToggleLeftIcon className="h-3.5 w-3.5" />,
date: <CalendarIcon className="h-3.5 w-3.5" />,
};
const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string): string => {
if (record.value_text != null) return record.value_text;
if (record.value_number != null) return String(record.value_number);
if (record.value_boolean != null) return record.value_boolean ? t("common.yes") : t("common.no");
if (record.value_date != null) return formatDateForDisplay(new Date(record.value_date), locale);
return "—";
};
const formatSourceType = (sourceType: string, t: TFunction): string => {
switch (sourceType) {
case "formbricks":
case "formbricks_survey":
return t("workspace.unify.formbricks_surveys");
case "csv":
return t("workspace.unify.csv_import");
default:
return sourceType;
}
};
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen) + "…";
}
interface FeedbackRecordsTableProps {
workspaceId: string;
initialRecords: FeedbackRecordData[];
frdMap: Record<string, string>;
csvSources: { id: string; name: string }[];
canWrite: boolean;
}
export const FeedbackRecordsTable = ({
workspaceId,
initialRecords,
frdMap,
csvSources,
canWrite,
}: Readonly<FeedbackRecordsTableProps>) => {
const { t, i18n } = useTranslation();
const [records, setRecords] = useState<FeedbackRecordData[]>(initialRecords);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit");
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
const directories = useMemo(
() =>
Object.entries(frdMap)
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name)),
[frdMap]
);
const feedbackDirectoryName = useMemo(() => {
const directoryNames = Array.from(
new Set(
records
.map((record) => frdMap[record.tenant_id])
.filter((directoryName): directoryName is string => Boolean(directoryName))
)
);
if (directoryNames.length > 0) {
return directoryNames.join(", ");
}
return directories[0]?.name ?? "—";
}, [directories, frdMap, records]);
const handleRefresh = async () => {
if (isRefreshing) return;
setIsRefreshing(true);
setError(null);
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
const directoryIds = Object.keys(frdMap);
const results = await Promise.all(
directoryIds.map((frdId) =>
listFeedbackRecordsAction({
workspaceId,
frdId,
limit: RECORDS_PER_PAGE,
})
)
);
const successfulRecords = results.flatMap((result) => result?.data?.data ?? []);
if (directoryIds.length > 0 && successfulRecords.length === 0) {
const firstErrorResult = results.find((result) => !result?.data);
const errorMessage = firstErrorResult ? getFormattedErrorMessage(firstErrorResult) : undefined;
toast.error(errorMessage ?? t("workspace.unify.failed_to_load_feedback_records"), {
id: toastId,
});
setIsRefreshing(false);
return;
}
const mergedRecords = successfulRecords
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.slice(0, RECORDS_PER_PAGE);
setRecords(mergedRecords);
setIsRefreshing(false);
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
};
if (error) {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex h-48 flex-col items-center justify-center gap-3 px-4 text-center">
<MessageSquareTextIcon className="h-8 w-8 text-slate-400" />
<p className="text-sm text-slate-500">{error}</p>
<Button variant="secondary" size="sm" onClick={handleRefresh}>
{t("common.retry")}
</Button>
</div>
</div>
);
}
const isEmpty = records.length === 0 && !isRefreshing;
const openEditDrawer = (recordId: string) => {
setDrawerMode("edit");
setDrawerRecordId(recordId);
setIsDrawerOpen(true);
};
const openCreateDrawer = () => {
setDrawerMode("create");
setDrawerRecordId(undefined);
setIsDrawerOpen(true);
};
const hasCsvSources = csvSources.length > 0;
return (
<>
<div className="space-y-3">
<div className="flex items-center justify-between">
{isEmpty ? (
<span />
) : (
<p className="text-sm text-slate-500">
{t("workspace.unify.showing_count_loaded", {
count: records.length,
directoryName: feedbackDirectoryName,
})}
</p>
)}
<div className="flex items-center gap-2">
{canWrite &&
(hasCsvSources ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="secondary">
<PlusIcon className="h-4 w-4" />
{t("workspace.unify.add_feedback_record")}
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={openCreateDrawer}>
{t("workspace.unify.add_feedback_record")}
</DropdownMenuItem>
<DropdownMenuSeparator />
{csvSources.map((source) => (
<DropdownMenuItem
key={source.id}
onClick={() => {
setCsvImportSource(source);
}}>
{t("workspace.unify.import_via_source_name", { sourceName: source.name })}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button size="sm" variant="secondary" onClick={openCreateDrawer}>
<PlusIcon className="h-4 w-4" />
{t("workspace.unify.add_feedback_record")}
</Button>
))}
<Button size="sm" asChild>
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
{t("workspace.unify.manage_feedback_sources")}
</Link>
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
aria-label={t("workspace.unify.refresh_feedback_records")}>
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[900px]">
<thead>
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
</tr>
</thead>
{isEmpty ? (
<tbody>
<tr>
<td colSpan={7}>
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
</div>
</td>
</tr>
</tbody>
) : (
<tbody className="divide-y divide-slate-100">
{records.map((record) => (
<FeedbackRecordRow
key={record.id}
record={record}
workspaceId={workspaceId}
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
t={t}
onClick={() => openEditDrawer(record.id)}
/>
))}
</tbody>
)}
</table>
</div>
</div>
</div>
<FeedbackRecordFormDrawer
mode={drawerMode}
open={isDrawerOpen}
onOpenChange={setIsDrawerOpen}
workspaceId={workspaceId}
directories={directories}
canWrite={canWrite}
recordId={drawerMode === "edit" ? drawerRecordId : undefined}
onSuccess={handleRefresh}
/>
{csvImportSource && (
<CsvImportModal
open={csvImportSource !== null}
onOpenChange={(open) => {
if (!open) {
setCsvImportSource(null);
}
}}
connectorId={csvImportSource.id}
workspaceId={workspaceId}
/>
)}
</>
);
};
const FeedbackRecordRow = ({
record,
workspaceId,
locale,
t,
onClick,
}: {
record: FeedbackRecordData;
workspaceId: string;
locale: string;
t: TFunction;
onClick: () => void;
}) => {
const value = formatValue(record, t, locale);
const isLongValue = value.length > 60;
const isFormbricksSurveySource =
(record.source_type === "formbricks" || record.source_type === "formbricks_survey") && !!record.source_id;
const surveySummaryHref = `/workspaces/${workspaceId}/surveys/${record.source_id}/summary`;
return (
<tr
className="cursor-pointer text-sm text-slate-700 transition-colors hover:bg-slate-50"
onClick={onClick}>
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
</td>
<td className="whitespace-nowrap px-4 py-3">
<Badge text={formatSourceType(record.source_type, t)} type="gray" size="tiny" />
</td>
<td className="max-w-[150px] truncate px-4 py-3" title={record.source_name ?? undefined}>
{isFormbricksSurveySource ? (
<Link
href={surveySummaryHref}
className="text-slate-700 underline underline-offset-2 hover:text-slate-900"
onClick={(event) => event.stopPropagation()}>
{record.source_name ?? "—"}
</Link>
) : (
<span>{record.source_name ?? "—"}</span>
)}
</td>
<td className="max-w-[200px] truncate px-4 py-3" title={record.field_label ?? undefined}>
{record.field_label ?? record.field_id}
</td>
<td className="whitespace-nowrap px-4 py-3">
<span className="inline-flex items-center gap-1 text-slate-600">
{FIELD_TYPE_ICONS[record.field_type] ?? <HashIcon className="h-3.5 w-3.5" />}
{record.field_type}
</span>
</td>
<td className="max-w-[250px] px-4 py-3">
{isLongValue ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default truncate">{truncate(value, 60)}</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm whitespace-pre-wrap">
{value}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span>{value}</span>
)}
</td>
<td className="max-w-[120px] truncate px-4 py-3 text-slate-500" title={record.user_identifier}>
{record.user_identifier ?? "—"}
</td>
</tr>
);
};
@@ -0,0 +1,61 @@
import { notFound } from "next/navigation";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getTranslate } from "@/lingodotdev/server";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { listFeedbackRecords } from "@/modules/hub/service";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { FeedbackRecordsPageClient } from "./feedback-records-page-client";
const INITIAL_PAGE_SIZE = 50;
export default async function UnifyFeedbackRecordsPage(
props: Readonly<{ params: Promise<{ workspaceId: string }> }>
) {
const t = await getTranslate();
const params = await props.params;
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
await getWorkspaceAuth(params.workspaceId);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
const canWrite = isOwner || isManager || hasReadWriteAccess || hasManageAccess;
if (!hasAccess) {
return notFound();
}
const [frds, connectors] = await Promise.all([
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
getConnectorsWithMappings(params.workspaceId),
]);
const results = await Promise.all(
frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE }))
);
// Don't crash if Hub is unreachable — show empty state
const successfulResults = results.filter((r) => !r.error);
const merged = successfulResults
.flatMap((r) => r.data?.data ?? [])
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.slice(0, INITIAL_PAGE_SIZE);
const frdMap = Object.fromEntries(frds.map((f) => [f.id, f.name]));
const csvSources = connectors
.filter((connector) => connector.type === "csv")
.map((connector) => ({ id: connector.id, name: connector.name }));
return (
<FeedbackRecordsPageClient
workspaceId={params.workspaceId}
initialRecords={merged}
frdMap={frdMap}
csvSources={csvSources}
canWrite={canWrite}
/>
);
}
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default async function UnifyPage(props: { params: Promise<{ workspaceId: string }> }) {
const params = await props.params;
redirect(`/workspaces/${params.workspaceId}/unify/feedback-records`);
}
@@ -0,0 +1,38 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { getSurveys } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { transformToUnifySurvey } from "./lib";
import { TUnifySurvey } from "./types";
const ZGetSurveysForUnifyAction = z.object({
workspaceId: ZId,
});
export const getSurveysForUnifyAction = authenticatedActionClient
.schema(ZGetSurveysForUnifyAction)
.action(async ({ ctx, parsedInput }): Promise<TUnifySurvey[]> => {
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "member"],
},
{
type: "workspaceTeam",
minPermission: "read",
workspaceId: parsedInput.workspaceId,
},
],
});
const surveys = await getSurveys(parsedInput.workspaceId);
return surveys.map((survey) => transformToUnifySurvey(survey));
});
@@ -0,0 +1,26 @@
"use client";
import { FileSpreadsheetIcon, FormIcon } from "lucide-react";
import { TConnectorType } from "@formbricks/types/connector";
export const getConnectorIcon = (type: TConnectorType, className: string) => {
switch (type) {
case "formbricks_survey":
return <FormIcon className={className} />;
case "csv":
return <FileSpreadsheetIcon className={className} />;
default:
return <FormIcon className={className} />;
}
};
export const getConnectorTypeLabelKey = (type: TConnectorType): string => {
switch (type) {
case "formbricks_survey":
return "workspace.unify.formbricks_surveys";
case "csv":
return "workspace.unify.csv_import";
default:
return type;
}
};
@@ -0,0 +1,21 @@
import { FEEDBACK_RECORD_FIELDS, TFieldMapping } from "../types";
export const isConnectorNameValid = (name: string): boolean => name.trim().length > 0;
export const areAllRequiredFieldsMapped = (mappings: TFieldMapping[]): boolean => {
const requiredFieldIds = new Set(
FEEDBACK_RECORD_FIELDS.filter((field) => field.required).map((field) => field.id)
);
for (const mapping of mappings) {
if (!requiredFieldIds.has(mapping.targetFieldId)) {
continue;
}
if (mapping.sourceFieldId || mapping.staticValue) {
requiredFieldIds.delete(mapping.targetFieldId);
}
}
return requiredFieldIds.size === 0;
};
@@ -0,0 +1,184 @@
"use client";
import {
CopyIcon,
EyeIcon,
FileSpreadsheetIcon,
MoreVertical,
PauseIcon,
PlayIcon,
SquarePenIcon,
TrashIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
interface ConnectorRowDropdownProps {
connector: TConnectorWithMappings;
onEdit: () => void;
onCsvImport?: () => void;
onDuplicate: () => Promise<void>;
onToggleStatus: () => Promise<void>;
onDelete: () => Promise<void>;
}
export function ConnectorRowDropdown({
connector,
onEdit,
onCsvImport,
onDuplicate,
onToggleStatus,
onDelete,
}: ConnectorRowDropdownProps) {
const router = useRouter();
const { t } = useTranslation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isActive = connector.status === "active";
const linkedSurveyId =
connector.type === "formbricks_survey" ? connector.formbricksMappings[0]?.surveyId : undefined;
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("workspace.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" && onCsvImport && (
<>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
onCsvImport();
}}>
<FileSpreadsheetIcon className="mr-2 h-4 w-4" />
{t("workspace.unify.import_csv_data")}
</button>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{linkedSurveyId && (
<>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
router.push(`/workspaces/${connector.workspaceId}/surveys/${linkedSurveyId}/summary`);
}}>
<EyeIcon className="mr-2 h-4 w-4" />
{`${t("common.view")} ${t("common.survey")}`}
</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("workspace.unify.source")}
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</div>
);
}
@@ -0,0 +1,82 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { TConnectorOptionId, getConnectorOptions } from "../utils";
interface ConnectorTypeSelectorProps {
selectedType: TConnectorOptionId | null;
onSelectType: (type: TConnectorOptionId) => void;
}
const getOptionClassName = (
selectedType: TConnectorOptionId | null,
optionId: TConnectorOptionId,
disabled: boolean
): string => {
if (selectedType === optionId) {
return "border-brand-dark bg-slate-50";
}
if (disabled) {
return "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60";
}
return "border-slate-200 hover:border-slate-300 hover:bg-slate-50";
};
export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<ConnectorTypeSelectorProps>) {
const { t } = useTranslation();
const connectorOptions = getConnectorOptions(t);
return (
<div className="space-y-3">
<div className="space-y-2">
{connectorOptions.map((option) => (
<button
key={option.id}
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id)}
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
selectedType,
option.id,
option.disabled
)}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
</div>
<div
className={`ml-3 h-4 w-4 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-1.5 w-1.5 rounded-full bg-white" />
</div>
)}
</div>
</button>
))}
</div>
<Alert variant="outbound" size="small">
<AlertTitle>{t("workspace.unify.missing_feedback_source_title")}</AlertTitle>
<AlertButton asChild>
<Link
href="https://app.formbricks.com/s/cmob8tub9s2ndu5010ei4it0g"
target="_blank"
rel="noopener noreferrer"
className="text-slate-900 hover:underline">
{t("workspace.unify.request_feedback_source")}
</Link>
</AlertButton>
</Alert>
</div>
);
}
@@ -0,0 +1,238 @@
"use client";
import Link from "next/link";
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 { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import {
createConnectorWithMappingsAction,
deleteConnectorAction,
duplicateConnectorAction,
updateConnectorWithMappingsAction,
} from "@/lib/connector/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { WorkspaceConfigNavigation } from "@/modules/workspaces/settings/components/workspace-config-navigation";
import { TFieldMapping, TUnifySurvey } from "../types";
import { ConnectorsTable } from "./connectors-table";
import { CreateConnectorModal } from "./create-connector-modal";
import { CsvImportModal } from "./csv-import-modal";
import { EditConnectorModal } from "./edit-connector-modal";
interface ConnectorsSectionProps {
workspaceId: string;
initialConnectors: TConnectorWithMappings[];
initialSurveys: TUnifySurvey[];
directories: { id: string; name: string }[];
}
export function ConnectorsSection({
workspaceId,
initialConnectors,
initialSurveys,
directories,
}: Readonly<ConnectorsSectionProps>) {
const { t } = useTranslation();
const router = useRouter();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingConnector, setEditingConnector] = useState<TConnectorWithMappings | null>(null);
const [csvImportConnector, setCsvImportConnector] = useState<TConnectorWithMappings | null>(null);
const directoryNames = directories.map((directory) => directory.name).join(", ");
const feedbackDirectoryAccessText =
directories.length === 1
? t("workspace.unify.feedback_sources_directory_access_single", {
directoryNames,
})
: t("workspace.unify.feedback_sources_directory_access_multiple", {
directoryNames,
});
const handleCreateConnector = async (data: {
name: string;
type: TConnectorType;
feedbackRecordDirectoryId: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}): Promise<string | undefined> => {
const result = await createConnectorWithMappingsAction({
workspaceId: workspaceId,
connectorInput: {
name: data.name,
type: data.type,
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
},
formbricksMappings:
data.type === "formbricks_survey" && data.surveyMappings?.length ? data.surveyMappings : undefined,
fieldMappings:
data.type !== "formbricks_survey" && 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("workspace.unify.connector_created_successfully"));
router.refresh();
return result.data.id;
};
const handleUpdateConnector = async (data: {
connectorId: string;
workspaceId: string;
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => {
const result = await updateConnectorWithMappingsAction({
connectorId: data.connectorId,
workspaceId: workspaceId,
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("workspace.unify.connector_updated_successfully"));
router.refresh();
};
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
const result = await deleteConnectorAction({ connectorId, workspaceId: workspaceId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("workspace.unify.connector_deleted_successfully"));
router.refresh();
};
const handleDuplicateConnector = async (connector: TConnectorWithMappings): Promise<void> => {
const result = await duplicateConnectorAction({
connectorId: connector.id,
workspaceId: workspaceId,
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("workspace.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,
workspaceId: workspaceId,
connectorInput: { status: newStatus },
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("workspace.unify.connector_status_updated_successfully"));
router.refresh();
};
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.workspace_configuration")}>
<WorkspaceConfigNavigation activeId="feedback-sources" />
</PageHeader>
<SettingsCard
title={t("workspace.unify.feedback_sources")}
description={t("workspace.unify.feedback_sources_settings_description")}
buttonInfo={{
text: t("workspace.unify.add_source"),
onClick: () => setIsCreateModalOpen(true),
variant: "default",
}}>
<ConnectorsTable
connectors={initialConnectors}
onConnectorClick={setEditingConnector}
onCsvImport={setCsvImportConnector}
onDuplicate={handleDuplicateConnector}
onToggleStatus={handleToggleStatus}
onDelete={handleDeleteConnector}
isLoading={false}
/>
{directories.length > 0 && (
<Alert size="small" className="mt-4">
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
<AlertButton asChild>
<Link href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
{t("workspace.unify.manage_directories")}
</Link>
</AlertButton>
</Alert>
)}
</SettingsCard>
<CreateConnectorModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreateConnector={handleCreateConnector}
surveys={initialSurveys}
workspaceId={workspaceId}
directories={directories}
showTrigger={false}
/>
<EditConnectorModal
connector={editingConnector}
open={editingConnector !== null}
onOpenChange={(open) => !open && setEditingConnector(null)}
onUpdateConnector={handleUpdateConnector}
surveys={initialSurveys}
onOpenCsvImport={() => {
if (editingConnector) {
setCsvImportConnector(editingConnector);
}
}}
/>
{csvImportConnector && (
<CsvImportModal
open={csvImportConnector !== null}
onOpenChange={(open) => !open && setCsvImportConnector(null)}
connectorId={csvImportConnector.id}
workspaceId={csvImportConnector.workspaceId}
onOpenEditConnector={() => {
setEditingConnector(csvImportConnector);
}}
/>
)}
</PageContentWrapper>
);
}
@@ -0,0 +1,124 @@
"use client";
import { useTranslation } from "react-i18next";
import { TConnectorStatus, TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
import { Badge } from "@/modules/ui/components/badge";
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
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;
onCsvImport?: () => void;
onDuplicate: () => Promise<void>;
onToggleStatus: () => Promise<void>;
onDelete: () => Promise<void>;
}
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
active: "success",
paused: "warning",
error: "error",
};
export function ConnectorsTableDataRow({
connector,
onEdit,
onCsvImport,
onDuplicate,
onToggleStatus,
onDelete,
}: Readonly<ConnectorsTableDataRowProps>) {
const { t, i18n } = useTranslation();
const handleRowClick = () => {
if (connector.type === "csv" && onCsvImport) {
onCsvImport();
return;
}
onEdit();
};
const getStatusLabel = (s: TConnectorStatus, connectorType: TConnectorType) => {
switch (s) {
case "active":
if (connectorType === "csv") {
return t("workspace.unify.status_ready");
}
return t("workspace.unify.status_live_sync");
case "paused":
return t("workspace.unify.status_paused");
case "error":
return t("workspace.unify.status_error");
}
};
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={handleRowClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleRowClick();
}
}}>
<div
className="col-span-1 flex items-center gap-2 pl-4"
title={t(getConnectorTypeLabelKey(connector.type))}>
{getConnectorIcon(connector.type, "h-4 w-4 text-slate-500")}
</div>
<div className="col-span-5 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, connector.type)}
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.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}
onCsvImport={onCsvImport}
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
</div>
</div>
);
}
@@ -0,0 +1,47 @@
import { useTranslation } from "react-i18next";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { ConnectorsTableDataRow } from "./connectors-table-data-row";
interface ConnectorsTableRowsContainerProps {
connectors: TConnectorWithMappings[];
onConnectorClick: (connector: TConnectorWithMappings) => void;
onCsvImport: (connector: TConnectorWithMappings) => void;
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
onDelete: (connectorId: string) => Promise<void>;
}
export const ConnectorsTableRowsContainer = ({
connectors,
onConnectorClick,
onCsvImport,
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("workspace.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)}
onCsvImport={connector.type === "csv" ? () => onCsvImport(connector) : undefined}
onDuplicate={() => onDuplicate(connector)}
onToggleStatus={() => onToggleStatus(connector)}
onDelete={() => onDelete(connector.id)}
/>
))}
</div>
);
};
@@ -0,0 +1,55 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { ConnectorsTableRowsContainer } from "./connectors-table-rows-container";
interface ConnectorsTableProps {
connectors: TConnectorWithMappings[];
onConnectorClick: (connector: TConnectorWithMappings) => void;
onCsvImport: (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,
onCsvImport,
onDuplicate,
onToggleStatus,
onDelete,
isLoading = false,
}: Readonly<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-5">{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("workspace.unify.updated_at")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("workspace.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}
onCsvImport={onCsvImport}
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
)}
</div>
);
}
@@ -0,0 +1,640 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2Icon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { z } from "zod";
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 {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Switch } from "@/modules/ui/components/switch";
import { TCreateConnectorStep, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
import {
TConnectorOptionId,
TEnumValidationError,
parseCSVColumnsToFields,
validateEnumMappings,
} from "../utils";
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
import { ConnectorTypeSelector } from "./connector-type-selector";
import { CsvConnectorUI } from "./csv-connector-ui";
import { FormbricksQuestionList } from "./formbricks-question-list";
interface CreateConnectorModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
showTrigger?: boolean;
onCreateConnector: (data: {
name: string;
type: TConnectorType;
feedbackRecordDirectoryId: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => Promise<string | undefined>;
surveys: TUnifySurvey[];
workspaceId: string;
directories: { id: string; name: string }[];
}
const getDialogTitle = (
step: TCreateConnectorStep,
type: TConnectorOptionId | null,
t: (key: string) => string
): string => {
if (step === "selectType") return t("workspace.unify.add_feedback_source");
if (type === "formbricks_survey") return t("workspace.unify.select_survey_and_questions");
if (type === "csv") return t("workspace.unify.import_csv_data");
return t("workspace.unify.configure_mapping");
};
const getDialogDescription = (
step: TCreateConnectorStep,
type: TConnectorOptionId | null,
t: (key: string) => string
): string => {
if (step === "selectType") return t("workspace.unify.select_source_type_description");
if (type === "formbricks_survey") return t("workspace.unify.select_survey_questions_description");
if (type === "csv") return t("workspace.unify.upload_csv_data_description");
return t("workspace.unify.configure_mapping");
};
const getNextStepButtonLabel = (type: TConnectorOptionId | null, t: (key: string) => string): string => {
if (type === "formbricks_survey") return t("workspace.unify.select_questions");
if (type === "csv") return t("workspace.unify.configure_import");
if (type === "api_ingestion") return t("workspace.unify.api_ingestion_manage_api_keys");
if (type === "feedback_record_mcp") return t("common.learn_more");
return t("workspace.unify.create_mapping");
};
const ZFormbricksConnectorForm = z.object({
sourceName: z.string().trim().min(1),
surveyId: z.string().min(1),
selectedQuestionIds: z.array(z.string()).min(1),
importHistorical: z.boolean(),
});
type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
const getSelectableQuestionIds = (survey: TUnifySurvey): string[] =>
survey.elements
.filter((element) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(element.type))
.map((element) => element.id);
export const CreateConnectorModal = ({
open,
onOpenChange,
showTrigger = true,
onCreateConnector,
surveys,
workspaceId,
directories,
}: CreateConnectorModalProps) => {
const { t } = useTranslation();
const router = useRouter();
const defaultConnectorName = useMemo<Record<TConnectorType, string>>(
() => ({
formbricks_survey: t("workspace.unify.default_connector_name_formbricks"),
csv: t("workspace.unify.default_connector_name_csv"),
}),
[t]
);
const formbricksForm = useForm<TFormbricksConnectorForm>({
resolver: zodResolver(ZFormbricksConnectorForm),
defaultValues: {
sourceName: defaultConnectorName.formbricks_survey,
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
},
mode: "onChange",
});
const [currentStep, setCurrentStep] = useState<TCreateConnectorStep>("selectType");
const [selectedType, setSelectedType] = useState<TConnectorOptionId | null>(null);
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [csvParsedData, setCsvParsedData] = useState<Record<string, string>[]>([]);
const [enumValidationErrors, setEnumValidationErrors] = useState<TEnumValidationError[]>([]);
const [csvConnectorName, setCsvConnectorName] = useState("");
const [responseCountBySurvey, setResponseCountBySurvey] = useState<Record<string, number | null>>({});
const [isImporting, setIsImporting] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories[0]?.id ?? null);
const formbricksValues = formbricksForm.watch();
const selectedSurveyId = formbricksValues.surveyId;
const selectedQuestionIds = formbricksValues.selectedQuestionIds ?? [];
const selectedSurvey = useMemo(
() => surveys.find((survey) => survey.id === selectedSurveyId) ?? null,
[surveys, selectedSurveyId]
);
const selectedSurveyResponseCount =
selectedSurveyId && responseCountBySurvey[selectedSurveyId] !== undefined
? responseCountBySurvey[selectedSurveyId]
: null;
const fetchResponseCount = useCallback(
async (surveyId: string) => {
if (responseCountBySurvey[surveyId] !== undefined) return;
try {
const result = await getResponseCountAction({ surveyId, workspaceId });
if (result?.data !== undefined) {
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: result.data ?? null }));
}
} catch {
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: null }));
}
},
[responseCountBySurvey, workspaceId]
);
useEffect(() => {
if (selectedSurveyId && currentStep === "mapping" && selectedType === "formbricks_survey") {
fetchResponseCount(selectedSurveyId);
}
}, [currentStep, fetchResponseCount, selectedSurveyId, selectedType]);
useEffect(() => {
if (currentStep !== "mapping" || selectedType !== "formbricks_survey" || !selectedSurveyId) {
return;
}
const survey = surveys.find((item) => item.id === selectedSurveyId);
const supportedElementIds = survey ? getSelectableQuestionIds(survey) : [];
formbricksForm.setValue("selectedQuestionIds", supportedElementIds, {
shouldDirty: true,
shouldValidate: true,
});
formbricksForm.setValue("importHistorical", true, {
shouldDirty: true,
});
}, [currentStep, formbricksForm, selectedSurveyId, selectedType, surveys]);
const resetForm = () => {
setCurrentStep("selectType");
setSelectedType(null);
formbricksForm.reset({
sourceName: defaultConnectorName.formbricks_survey,
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
});
setMappings([]);
setSourceFields([]);
setCsvParsedData([]);
setEnumValidationErrors([]);
setResponseCountBySurvey({});
setCsvConnectorName("");
setIsImporting(false);
setIsCreating(false);
setSelectedDirectoryId(directories[0]?.id ?? null);
};
const handleOpenChange = (newOpen: boolean) => {
if (isImporting) return;
if (!newOpen) resetForm();
onOpenChange(newOpen);
};
const handleNextStep = () => {
if (currentStep !== "selectType" || !selectedType) return;
if (selectedType === "api_ingestion") {
handleOpenChange(false);
router.push(`/workspaces/${workspaceId}/settings/api-keys`);
return;
}
if (selectedType === "feedback_record_mcp") {
window.open("https://formbricks.com/docs", "_blank", "noopener,noreferrer");
return;
}
if (selectedType === "formbricks_survey") {
formbricksForm.reset({
sourceName: defaultConnectorName.formbricks_survey,
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
});
}
if (selectedType === "csv") {
setCsvConnectorName(defaultConnectorName.csv);
}
setCurrentStep("mapping");
};
const handleBack = () => {
if (currentStep === "mapping") {
setCurrentStep("selectType");
setMappings([]);
setSourceFields([]);
setEnumValidationErrors([]);
}
};
const handleHistoricalImport = async (connectorId: string, surveyId: string) => {
const responseCount = responseCountBySurvey[surveyId] ?? 0;
if (responseCount <= 0) return;
setIsImporting(true);
const importResult = await importHistoricalResponsesAction({
connectorId,
workspaceId,
surveyId,
});
setIsImporting(false);
if (importResult?.data) {
toast.success(
t("workspace.unify.historical_import_complete", {
successes: importResult.data.successes,
failures: importResult.data.failures,
skipped: importResult.data.skipped,
})
);
} else {
toast.error(getFormattedErrorMessage(importResult));
}
};
const handleCsvImport = async (connectorId: string) => {
setIsImporting(true);
const importResult = await importCsvDataAction({
connectorId,
workspaceId,
csvData: csvParsedData,
});
setIsImporting(false);
if (importResult?.data) {
toast.success(
t("workspace.unify.csv_import_complete", {
successes: importResult.data.successes,
failures: importResult.data.failures,
skipped: importResult.data.skipped,
})
);
} else {
toast.error(getFormattedErrorMessage(importResult));
}
};
const handleFormbricksQuestionToggle = (questionId: string) => {
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
const isSelected = currentSelection.includes(questionId);
const nextSelection = isSelected
? currentSelection.filter((id) => id !== questionId)
: [...currentSelection, questionId];
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
shouldDirty: true,
shouldValidate: true,
});
};
const handleCreateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
if (!selectedDirectoryId) return;
setIsCreating(true);
const connectorId = await onCreateConnector({
name: values.sourceName.trim(),
type: "formbricks_survey",
feedbackRecordDirectoryId: selectedDirectoryId,
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
});
if (connectorId && values.importHistorical) {
await handleHistoricalImport(connectorId, values.surveyId);
}
setIsCreating(false);
resetForm();
onOpenChange(false);
};
const handleCreateCsvConnector = async () => {
if (!selectedDirectoryId || !isConnectorNameValid(csvConnectorName)) return;
if (csvParsedData.length > 0) {
const errors = validateEnumMappings(mappings, csvParsedData);
if (errors.length > 0) {
setEnumValidationErrors(errors);
return;
}
setEnumValidationErrors([]);
}
setIsCreating(true);
const connectorId = await onCreateConnector({
name: csvConnectorName.trim(),
type: "csv",
feedbackRecordDirectoryId: selectedDirectoryId,
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
if (connectorId && csvParsedData.length > 0) {
await handleCsvImport(connectorId);
}
setIsCreating(false);
resetForm();
onOpenChange(false);
};
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
const areCsvRequiredFieldsMapped = areAllRequiredFieldsMapped(mappings);
const handleLoadSourceFields = () => {
if (selectedType === "csv") {
const fields = parseCSVColumnsToFields("timestamp,customer_id,rating,feedback_text,category");
setSourceFields(fields);
}
};
return (
<>
{showTrigger && (
<Button onClick={() => onOpenChange(true)} size="sm">
{t("workspace.unify.add_source")}
<PlusIcon className="ml-2 h-4 w-4" />
</Button>
)}
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
{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("workspace.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_survey" && (
<FormProvider {...formbricksForm}>
<form className="space-y-4">
<FormField
control={formbricksForm.control}
name="sourceName"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
<Input
value={field.value}
onChange={field.onChange}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
{directories.length === 0 && (
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
)}
<FormField
control={formbricksForm.control}
name="surveyId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t("workspace.unify.select_survey")} />
</SelectTrigger>
<SelectContent>
{surveys.map((survey) => (
<SelectItem key={survey.id} value={survey.id}>
{survey.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={formbricksForm.control}
name="selectedQuestionIds"
render={() => (
<FormItem>
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
<FormControl>
<div>
<FormbricksQuestionList
survey={selectedSurvey}
selectedQuestionIds={selectedQuestionIds}
onQuestionToggle={handleFormbricksQuestionToggle}
/>
</div>
</FormControl>
<FormError />
</FormItem>
)}
/>
{selectedSurveyResponseCount !== null && selectedSurveyResponseCount > 0 && (
<FormField
control={formbricksForm.control}
name="importHistorical"
render={({ field }) => (
<FormItem className="rounded-md border border-slate-200 p-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<FormLabel>{t("workspace.unify.import_historical_responses")}</FormLabel>
<p className="text-sm text-slate-500">
{t("workspace.unify.import_historical_responses_description")}
</p>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormItem>
)}
/>
)}
</form>
</FormProvider>
)}
{currentStep === "mapping" && selectedType === "csv" && (
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="connectorName" className="text-sm font-medium text-slate-700">
{t("workspace.unify.source_name")}
</label>
<Input
id="connectorName"
value={csvConnectorName}
onChange={(event) => setCsvConnectorName(event.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</div>
{directories.length === 0 && (
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
)}
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 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("workspace.unify.invalid_enum_values", {
field: err.targetFieldName,
})}
</p>
<p>
{t("workspace.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("workspace.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={
selectedType === "formbricks_survey"
? () => void formbricksForm.handleSubmit(handleCreateFormbricksConnector)()
: handleCreateCsvConnector
}
disabled={
isCreating ||
isImporting ||
!selectedDirectoryId ||
(selectedType === "formbricks_survey"
? !isConnectorNameValid(formbricksValues.sourceName ?? "") ||
!formbricksValues.surveyId ||
!formbricksValues.selectedQuestionIds?.length
: !isConnectorNameValid(csvConnectorName) || !isCsvValid || !areCsvRequiredFieldsMapped)
}>
{isCreating && <Loader2Icon className="mr-2 h-4 w-4 animate-spin" />}
{t("workspace.unify.setup_connection")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
interface NoFeedbackRecordDirectoryAlertProps {
workspaceId: string;
t: (key: string) => string;
}
const NoFeedbackRecordDirectoryAlert = ({ workspaceId, t }: NoFeedbackRecordDirectoryAlertProps) => {
return (
<Alert variant="error" size="small">
<div>
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
<a
className="mt-1 inline-block font-medium underline"
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
{t("workspace.unify.go_to_feedback_record_directories")}
</a>
</div>
</Alert>
);
};
@@ -0,0 +1,220 @@
"use client";
import { parse } from "csv-parse/sync";
import { ArrowUpFromLineIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { TFieldMapping, TSourceField, createFeedbackCSVDataSchema } from "../types";
import { validateCsvFile } from "../utils";
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.issues[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("workspace.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("workspace.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("workspace.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("workspace.unify.click_to_upload")}</span>{" "}
{t("workspace.unify.or_drag_and_drop")}
</p>
<p className="mt-1 text-xs text-slate-400">{t("workspace.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("workspace.unify.load_sample_csv")}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,204 @@
"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 { 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { createFeedbackCSVDataSchema } from "../types";
import { validateCsvFile } from "../utils";
interface CsvImportModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
connectorId: string;
workspaceId: string;
onOpenEditConnector?: () => void;
}
export function CsvImportModal({
open,
onOpenChange,
connectorId,
workspaceId,
onOpenEditConnector,
}: CsvImportModalProps) {
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.issues[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, workspaceId, csvData: parsedData });
setIsImporting(false);
if (result?.data) {
toast.success(
t("workspace.unify.csv_import_complete", {
successes: result.data.successes,
failures: result.data.failures,
skipped: result.data.skipped,
})
);
setCsvFile(null);
setParsedData([]);
setRowCount(0);
onOpenChange(false);
} else {
toast.error(getFormattedErrorMessage(result));
}
};
const handleClear = () => {
setCsvFile(null);
setParsedData([]);
setRowCount(0);
setCsvError("");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("workspace.unify.import_csv_data")}</DialogTitle>
<DialogDescription>{t("workspace.unify.upload_csv_data_description")}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Alert variant="info" size="small">
{t("workspace.unify.csv_import_duplicate_warning")}
</Alert>
{csvError && (
<Alert variant="error" size="small">
{csvError}
</Alert>
)}
{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={`${rowCount} rows`} type="gray" size="tiny" />
</div>
<Button variant="secondary" size="sm" onClick={handleClear} disabled={isImporting}>
{t("workspace.unify.change_file")}
</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("workspace.unify.click_to_upload")}</span>{" "}
{t("workspace.unify.or_drag_and_drop")}
</p>
<p className="mt-1 text-xs text-slate-400">{t("workspace.unify.csv_files_only")}</p>
<input
type="file"
id="csv-import-upload"
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
</label>
</div>
)}
</div>
<DialogFooter>
{onOpenEditConnector && (
<Button
variant="secondary"
onClick={() => {
onOpenChange(false);
onOpenEditConnector();
}}>
{t("workspace.unify.edit_csv_mapping")}
</Button>
)}
<Button onClick={handleImport} disabled={parsedData.length === 0 || isImporting}>
{isImporting ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
{t("workspace.unify.importing_data")}
</>
) : (
t("workspace.unify.import_rows", { count: rowCount })
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,374 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { 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 {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
import { parseCSVColumnsToFields } from "../utils";
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
import { FormbricksQuestionList } from "./formbricks-question-list";
import { MappingUI } from "./mapping-ui";
interface EditConnectorModalProps {
connector: TConnectorWithMappings | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onUpdateConnector: (data: {
connectorId: string;
workspaceId: string;
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => Promise<void>;
surveys: TUnifySurvey[];
onOpenCsvImport?: () => void;
}
const ZFormbricksEditConnectorForm = z.object({
sourceName: z.string().trim().min(1),
surveyId: z.string().min(1),
selectedQuestionIds: z.array(z.string()).min(1),
importHistorical: z.boolean(),
});
type TFormbricksEditConnectorForm = z.infer<typeof ZFormbricksEditConnectorForm>;
export const EditConnectorModal = ({
connector,
open,
onOpenChange,
onUpdateConnector,
surveys,
onOpenCsvImport,
}: EditConnectorModalProps) => {
const { t } = useTranslation();
const [csvConnectorName, setCsvConnectorName] = useState("");
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [isUpdating, setIsUpdating] = useState(false);
const formbricksForm = useForm<TFormbricksEditConnectorForm>({
resolver: zodResolver(ZFormbricksEditConnectorForm),
defaultValues: {
sourceName: "",
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
},
mode: "onChange",
});
const formbricksValues = formbricksForm.watch();
const selectedSurveyId = formbricksValues.surveyId;
const selectedQuestionIds = formbricksValues.selectedQuestionIds ?? [];
const selectedSurvey = useMemo(
() => surveys.find((survey) => survey.id === selectedSurveyId) ?? null,
[surveys, selectedSurveyId]
);
useEffect(() => {
if (connector) {
if (connector.type === "formbricks_survey") {
const mappedSurveyId = connector.formbricksMappings[0]?.surveyId ?? "";
const mappedQuestionIds = connector.formbricksMappings
.filter((mapping) => mapping.surveyId === mappedSurveyId)
.map((mapping) => mapping.elementId);
formbricksForm.reset({
sourceName: connector.name,
surveyId: mappedSurveyId,
selectedQuestionIds: mappedQuestionIds,
importHistorical: true,
});
setCsvConnectorName("");
setSourceFields([]);
setMappings([]);
} else if (connector.type === "csv") {
setCsvConnectorName(connector.name);
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,
}))
);
formbricksForm.reset({
sourceName: "",
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
});
} else {
setCsvConnectorName("");
setSourceFields([]);
setMappings([]);
formbricksForm.reset({
sourceName: "",
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
});
}
}
}, [connector, formbricksForm]);
const resetForm = () => {
setCsvConnectorName("");
setMappings([]);
setSourceFields([]);
formbricksForm.reset({
sourceName: "",
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
});
setIsUpdating(false);
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm();
}
onOpenChange(newOpen);
};
const handleUpdateFormbricksConnector = async (values: TFormbricksEditConnectorForm) => {
if (connector?.type !== "formbricks_survey") return;
setIsUpdating(true);
await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: values.sourceName.trim(),
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
fieldMappings: undefined,
});
setIsUpdating(false);
handleOpenChange(false);
};
const handleUpdateCsvConnector = async () => {
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
setIsUpdating(true);
await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: csvConnectorName.trim(),
surveyMappings: undefined,
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
setIsUpdating(false);
handleOpenChange(false);
};
const handleFormbricksQuestionToggle = (questionId: string) => {
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
const isSelected = currentSelection.includes(questionId);
const nextSelection = isSelected
? currentSelection.filter((id) => id !== questionId)
: [...currentSelection, questionId];
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
shouldDirty: true,
shouldValidate: true,
});
};
const saveChangesDisabled = useMemo(() => {
if (!connector) return true;
if (isUpdating) return true;
if (connector.type === "formbricks_survey") {
return (
!isConnectorNameValid(formbricksValues.sourceName ?? "") ||
!formbricksValues.surveyId ||
!formbricksValues.selectedQuestionIds?.length
);
}
if (connector.type === "csv") {
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredFieldsMapped(mappings);
}
return true;
}, [connector, csvConnectorName, formbricksValues, isUpdating, mappings]);
if (!connector) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t("workspace.unify.edit_source_connection")}</DialogTitle>
<DialogDescription>{t("workspace.unify.update_mapping_description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{connector.type === "formbricks_survey" ? (
<FormProvider {...formbricksForm}>
<form className="space-y-4">
<FormField
control={formbricksForm.control}
name="sourceName"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
<Input
value={field.value}
onChange={field.onChange}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={formbricksForm.control}
name="surveyId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange} disabled>
<SelectTrigger>
<SelectValue placeholder={t("workspace.unify.select_survey")} />
</SelectTrigger>
<SelectContent>
{selectedSurvey && (
<SelectItem key={selectedSurvey.id} value={selectedSurvey.id}>
{selectedSurvey.name}
</SelectItem>
)}
{!selectedSurvey && field.value && (
<SelectItem value={field.value}>{field.value}</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={formbricksForm.control}
name="selectedQuestionIds"
render={() => (
<FormItem>
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
<FormControl>
<div>
<FormbricksQuestionList
survey={selectedSurvey}
selectedQuestionIds={selectedQuestionIds}
onQuestionToggle={handleFormbricksQuestionToggle}
/>
</div>
</FormControl>
<FormError />
</FormItem>
)}
/>
</form>
</FormProvider>
) : (
<>
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
{getConnectorIcon(connector.type, "h-5 w-5 text-slate-500")}
<div>
<p className="text-sm font-medium text-slate-900">
{t(getConnectorTypeLabelKey(connector.type))}
</p>
<p className="text-xs text-slate-500">
{t("workspace.unify.source_type_cannot_be_changed")}
</p>
</div>
</div>
<div className="space-y-2">
<label htmlFor="editConnectorName" className="text-sm font-medium text-slate-700">
{t("workspace.unify.source_name")}
</label>
<Input
id="editConnectorName"
value={csvConnectorName}
onChange={(event) => setCsvConnectorName(event.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</div>
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
connectorType={connector.type}
/>
</div>
</>
)}
</div>
<DialogFooter>
{connector.type === "csv" && (
<Button
variant="secondary"
onClick={() => {
handleOpenChange(false);
onOpenCsvImport?.();
}}>
{t("workspace.unify.import_feedback")}
</Button>
)}
<Button
onClick={
connector.type === "formbricks_survey"
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
: handleUpdateCsvConnector
}
disabled={saveChangesDisabled}>
{t("workspace.unify.save_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,80 @@
"use client";
import { useTranslation } from "react-i18next";
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { Label } from "@/modules/ui/components/label";
import { TUnifySurvey } from "../types";
interface FormbricksQuestionListProps {
survey: TUnifySurvey | null;
selectedQuestionIds: string[];
onQuestionToggle: (questionId: string) => void;
}
const isUnsupportedElementType = (type: string): boolean =>
(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(type);
export const FormbricksQuestionList = ({
survey,
selectedQuestionIds,
onQuestionToggle,
}: Readonly<FormbricksQuestionListProps>) => {
const { t } = useTranslation();
if (!survey) {
return (
<div className="rounded-md border border-dashed border-slate-300 p-3">
<p className="text-sm text-slate-500">{t("workspace.unify.select_a_survey_to_see_questions")}</p>
</div>
);
}
if (survey.elements.length === 0) {
return (
<div className="rounded-md border border-dashed border-slate-300 p-3">
<p className="text-sm text-slate-500">{t("workspace.unify.survey_has_no_questions")}</p>
</div>
);
}
return (
<div className="max-h-64 space-y-2 overflow-y-auto rounded-md border border-slate-200 p-3">
{survey.elements.map((element) => {
const unsupported = isUnsupportedElementType(element.type);
const isChecked = selectedQuestionIds.includes(element.id);
const elementTypeLabel = getTSurveyElementTypeEnumName(element.type, t) ?? element.type;
const inputId = `connector-question-${element.id}`;
return (
<div
key={element.id}
className={`flex items-start gap-3 rounded-md border border-slate-100 p-2 ${
unsupported ? "opacity-60" : ""
}`}>
<Checkbox
id={inputId}
checked={!unsupported && isChecked}
disabled={unsupported}
onCheckedChange={() => {
if (!unsupported) {
onQuestionToggle(element.id);
}
}}
/>
<div className="space-y-0.5">
<Label htmlFor={inputId} className={unsupported ? "cursor-not-allowed" : "cursor-pointer"}>
{element.headline}
</Label>
<p className="text-xs text-slate-500">{elementTypeLabel}</p>
{unsupported && (
<p className="text-xs text-slate-500">{t("workspace.unify.question_type_not_supported")}</p>
)}
</div>
</div>
);
})}
</div>
);
};
@@ -0,0 +1,358 @@
"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 const 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>
);
};
const getMappingStateClass = (isActive: boolean, hasMapping: unknown): string => {
if (isActive) return "border-brand-dark bg-slate-100";
if (hasMapping) return "border-green-300 bg-green-50";
return "border-dashed border-slate-300 bg-slate-50";
};
interface RemoveMappingButtonProps {
onClick: () => void;
variant: "green" | "blue";
}
const RemoveMappingButton = ({ onClick, variant }: RemoveMappingButtonProps) => {
const colorClass = variant === "green" ? "hover:bg-green-100" : "hover:bg-blue-100";
const iconClass = variant === "green" ? "text-green-600" : "text-blue-600";
return (
<button type="button" onClick={onClick} className={`ml-1 rounded p-0.5 ${colorClass}`}>
<XIcon className={`h-3 w-3 ${iconClass}`} />
</button>
);
};
interface EnumTargetFieldContentProps {
field: TTargetField;
mappedSourceField: TSourceField | null;
mapping: TFieldMapping | null;
onRemoveMapping: () => void;
onStaticValueChange: (value: string) => void;
t: (key: string) => string;
}
const EnumTargetFieldContent = ({
field,
mappedSourceField,
mapping,
onRemoveMapping,
onStaticValueChange,
t,
}: EnumTargetFieldContentProps) => {
return (
<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("workspace.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>
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
</div>
) : (
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
<SelectTrigger className="h-8 w-full bg-white">
<SelectValue placeholder={t("workspace.unify.select_a_value")} />
</SelectTrigger>
<SelectContent>
{field.enumValues?.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
};
interface StringTargetFieldContentProps {
field: TTargetField;
mappedSourceField: TSourceField | null;
mapping: TFieldMapping | null;
hasMapping: unknown;
onRemoveMapping: () => void;
onStaticValueChange: (value: string) => void;
t: (key: string) => string;
}
const StringTargetFieldContent = ({
field,
mappedSourceField,
mapping,
hasMapping,
onRemoveMapping,
onStaticValueChange,
t,
}: StringTargetFieldContentProps) => {
const [isEditingStatic, setIsEditingStatic] = useState(false);
const [customValue, setCustomValue] = useState("");
return (
<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>
{mappedSourceField && !mapping?.staticValue && (
<div className="flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
</div>
)}
{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>
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
</div>
)}
{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("workspace.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>
)}
{!hasMapping && !isEditingStatic && (
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">{t("workspace.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("workspace.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>
);
};
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 isActive = isOver || isOverCurrent;
const hasMapping = mappedSourceField || mapping?.staticValue;
const containerClass = cn(
"flex items-center gap-2 rounded-md border p-2 text-sm transition-colors",
getMappingStateClass(!!isActive, hasMapping)
);
if (field.type === "enum" && field.enumValues) {
return (
<div ref={setNodeRef} className={containerClass}>
<EnumTargetFieldContent
field={field}
mappedSourceField={mappedSourceField}
mapping={mapping}
onRemoveMapping={onRemoveMapping}
onStaticValueChange={onStaticValueChange}
t={t}
/>
</div>
);
}
if (field.type === "string") {
return (
<div ref={setNodeRef} className={containerClass}>
<StringTargetFieldContent
field={field}
mappedSourceField={mappedSourceField}
mapping={mapping}
hasMapping={hasMapping}
onRemoveMapping={onRemoveMapping}
onStaticValueChange={onStaticValueChange}
t={t}
/>
</div>
);
}
// Helper to get display label for static values
const getStaticValueLabel = (value: string) => {
if (value === "$now") return t("workspace.unify.feedback_date");
return value;
};
return (
<div ref={setNodeRef} className={containerClass}>
<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>
{mappedSourceField && !mapping?.staticValue && (
<div className="mt-1 flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
</div>
)}
{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>
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
</div>
)}
{!hasMapping && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">{t("workspace.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>
);
};
@@ -0,0 +1,146 @@
"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("workspace.unify.csv_columns") : t("workspace.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("workspace.unify.click_load_sample_csv")
: t("workspace.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("workspace.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("workspace.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("workspace.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="rounded-md border border-brand-dark 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>
);
}
@@ -0,0 +1,243 @@
import { describe, expect, test, vi } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { transformToUnifySurvey } from "./lib";
vi.mock("@formbricks/types/surveys/validation", () => ({
getTextContent: (str: string) => str,
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (val: Record<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);
});
});
@@ -0,0 +1,51 @@
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { recallToHeadline } from "@/lib/utils/recall";
import { TUnifySurvey, TUnifySurveyElement } from "./types";
const getElementHeadline = (element: TSurveyElement, survey: TSurvey): string => {
return (
getTextContent(
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
) || "Untitled"
);
};
const mapSurveyStatus = (status: string): TUnifySurvey["status"] => {
switch (status) {
case "inProgress":
return "active";
case "paused":
return "paused";
case "draft":
return "draft";
case "completed":
return "completed";
default:
return "draft";
}
};
export const transformToUnifySurvey = (survey: TSurvey): TUnifySurvey => {
const elements = getElementsFromBlocks(survey.blocks);
const unifySurveyElements: TUnifySurveyElement[] = elements
.filter((el) => el.type !== TSurveyElementTypeEnum.CTA)
.map((el) => ({
id: el.id,
type: el.type,
headline: getElementHeadline(el, survey),
required: el.required ?? false,
}));
return {
id: survey.id,
name: survey.name,
status: mapSurveyStatus(survey.status),
elements: unifySurveyElements,
createdAt: survey.createdAt,
};
};
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default async function UnifySourcesPage(props: { params: Promise<{ workspaceId: string }> }) {
const params = await props.params;
redirect(`/workspaces/${params.workspaceId}/feedback-sources`);
}
@@ -0,0 +1,212 @@
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("workspace.unify.csv_at_least_one_row") })
.max(MAX_CSV_VALUES.RECORDS, {
message: t("workspace.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("workspace.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("workspace.unify.csv_empty_column_headers"),
});
}
});
export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSchema>>;
export type TCreateConnectorStep = "selectType" | "mapping";
@@ -0,0 +1,117 @@
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, csv, api ingestion, and mcp options", () => {
const options = getConnectorOptions(mockT as never);
expect(options).toHaveLength(4);
expect(options[0].id).toBe("formbricks_survey");
expect(options[1].id).toBe("csv");
expect(options[2].id).toBe("api_ingestion");
expect(options[3].id).toBe("feedback_record_mcp");
});
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("workspace.unify.formbricks_surveys");
expect(options[0].description).toBe("workspace.unify.source_connect_formbricks_description");
expect(options[1].name).toBe("workspace.unify.csv_import");
expect(options[1].description).toBe("workspace.unify.source_connect_csv_description");
expect(options[2].name).toBe("workspace.unify.api_ingestion");
expect(options[2].description).toBe("workspace.unify.api_ingestion_settings_description");
expect(options[3].name).toBe("workspace.unify.feedback_record_mcp");
expect(options[3].description).toBe("workspace.unify.source_connect_feedback_record_mcp_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: "workspace.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: "workspace.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: "workspace.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: "workspace.unify.csv_files_only" });
});
});
@@ -0,0 +1,107 @@
import { TFunction } from "i18next";
import { TConnectorType, THubFieldType } from "@formbricks/types/connector";
import { FEEDBACK_RECORD_FIELDS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
export type TConnectorOptionId = TConnectorType | "api_ingestion" | "feedback_record_mcp";
export interface TConnectorOption {
id: TConnectorOptionId;
name: string;
description: string;
disabled: boolean;
badge?: { text: string; type: "success" | "gray" | "warning" };
}
export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
{
id: "formbricks_survey",
name: t("workspace.unify.formbricks_surveys"),
description: t("workspace.unify.source_connect_formbricks_description"),
disabled: false,
},
{
id: "csv",
name: t("workspace.unify.csv_import"),
description: t("workspace.unify.source_connect_csv_description"),
disabled: false,
},
{
id: "api_ingestion",
name: t("workspace.unify.api_ingestion"),
description: t("workspace.unify.api_ingestion_settings_description"),
disabled: false,
},
{
id: "feedback_record_mcp",
name: t("workspace.unify.feedback_record_mcp"),
description: t("workspace.unify.source_connect_feedback_record_mcp_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?.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("workspace.unify.csv_files_only") };
}
if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) {
return { valid: false, error: t("workspace.unify.csv_files_only") };
}
if (file.size > MAX_CSV_VALUES.FILE_SIZE) {
return { valid: false, error: t("workspace.unify.csv_file_too_large") };
}
return { valid: true };
};
+12 -3
View File
@@ -4,10 +4,10 @@ import { v7 as uuidv7 } from "uuid";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { handleConnectorPipeline } from "@/lib/connector/pipeline-handler";
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
@@ -21,11 +21,12 @@ import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
import { sendResponseFinishedEmail } from "@/modules/email";
import { handleIntegrations } from "@/modules/response-pipeline/lib/handle-integrations";
import { captureSurveyResponsePostHogEvent } from "@/modules/response-pipeline/lib/posthog";
import { sendTelemetryEvents } from "@/modules/response-pipeline/lib/telemetry";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
export const POST = async (request: Request) => {
const requestHeaders = await headers();
@@ -153,6 +154,14 @@ export const POST = async (request: Request) => {
});
if (event === "responseFinished") {
// Handle connector pipeline for Hub integration (only on responseFinished to avoid duplicates)
// This sends response data to the Hub for configured connectors
try {
await handleConnectorPipeline(response, survey, workspaceId);
} 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(workspaceId),
@@ -0,0 +1,98 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getResponseIdByDisplayId } from "./response";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs: [unknown, unknown][]) =>
inputs.map((input: [unknown, unknown]) => input[0])
),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
findFirst: vi.fn(),
},
},
}));
describe("getResponseIdByDisplayId", () => {
const workspaceId = "ws1234567890123456789012";
const displayId = "display1234567890123456789";
beforeEach(() => {
vi.clearAllMocks();
});
test("returns the linked responseId when a response exists", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: {
id: "response123456789012345678",
},
} as any);
const result = await getResponseIdByDisplayId(workspaceId, displayId);
expect(validateInputs).toHaveBeenCalledWith(
[workspaceId, expect.any(Object)],
[displayId, expect.any(Object)]
);
expect(prisma.display.findFirst).toHaveBeenCalledWith({
where: {
id: displayId,
survey: {
workspaceId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
expect(result).toEqual({ responseId: "response123456789012345678" });
});
test("returns null when the display exists but has no response", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: null,
} as any);
await expect(getResponseIdByDisplayId(workspaceId, displayId)).resolves.toEqual({
responseId: null,
});
});
test("throws ResourceNotFoundError when the display does not exist in the workspace", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue(null);
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(
new ResourceNotFoundError("Display", displayId)
);
});
test("throws ValidationError when input validation fails", async () => {
const validationError = new ValidationError("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(ValidationError);
expect(prisma.display.findFirst).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "test",
});
vi.mocked(prisma.display.findFirst).mockRejectedValue(prismaError);
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(DatabaseError);
});
});
@@ -0,0 +1,44 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getResponseIdByDisplayId = async (
workspaceId: string,
displayId: string
): Promise<{ responseId: string | null }> => {
validateInputs([workspaceId, ZId], [displayId, ZId]);
try {
const display = await prisma.display.findFirst({
where: {
id: displayId,
survey: {
workspaceId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
if (!display) {
throw new ResourceNotFoundError("Display", displayId);
}
return {
responseId: display.response?.id ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,49 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { getResponseIdByDisplayId } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: THandlerParams<{ params: Promise<{ workspaceId: string; displayId: string }> }>) => {
const params = await props.params;
const resolved = await resolveClientApiIds(params.workspaceId);
if (!resolved) {
return {
response: responses.notFoundResponse("Workspace", params.workspaceId, true),
};
}
const { workspaceId } = resolved;
try {
const response = await getResponseIdByDisplayId(workspaceId, params.displayId);
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Display", params.displayId, true),
};
}
logger.error(
{ error, url: req.url, workspaceId, displayId: params.displayId },
"Error in GET /api/v1/client/[workspaceId]/displays/[displayId]/response"
);
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
},
});
@@ -33,10 +33,13 @@ vi.mock("@formbricks/logger", () => ({
vi.mock("./data");
vi.mock("@/app/lib/api/api-backwards-compat", () => ({
addLegacyProjectOverwritesToList: vi.fn((surveys: unknown[]) =>
surveys.map((s: Record<string, unknown>) => ({
...s,
projectOverwrites: s.workspaceOverwrites ?? null,
}))
surveys.map((survey) => {
const typedSurvey = survey as Record<string, unknown>;
return {
...typedSurvey,
projectOverwrites: typedSurvey.workspaceOverwrites ?? null,
};
})
),
addLegacyProjectToEnvironmentState: vi.fn((data: Record<string, unknown>) => ({
...data,
@@ -129,7 +132,7 @@ const mockActionClasses = [
description: null,
type: "code",
noCodeConfig: null,
environmentId: workspaceId,
workspaceId,
key: "action1",
},
] as unknown as TActionClass[];
@@ -126,7 +126,6 @@ export const POST = withV1ApiWrapper({
response: responses.badRequestResponse(
"Survey is part of another workspace",
{
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
+7 -7
View File
@@ -106,7 +106,7 @@ const updateApiKeyUsage = async (apiKeyId: string) => {
});
};
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
const buildWorkspaceResponse = (apiKeyData: ApiKeyData) => {
const workspace = apiKeyData.apiKeyWorkspaces[0].workspace;
return Response.json({
// Keep v1 payload shape stable while sourcing data from workspace.
@@ -120,8 +120,10 @@ const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
name: workspace.name,
},
// Backwards compat: old consumers expect project fields
projectId: workspace.id,
projectName: workspace.name,
project: {
id: workspace.id,
name: workspace.name,
},
});
};
@@ -148,14 +150,12 @@ const handleApiKeyAuthentication = async (apiKey: string) => {
});
}
const rateLimitError = await checkRateLimit(apiKeyData.id);
if (rateLimitError) return rateLimitError;
// Rate limiting for apiKey auth is enforced by Envoy in v5 — see envoy-rate-limit-coverage.ts
if (!isValidApiKeyEnvironment(apiKeyData)) {
return responses.badRequestResponse("You can't use this method with this API key");
}
return buildEnvironmentResponse(apiKeyData);
return buildWorkspaceResponse(apiKeyData);
};
const handleSessionAuthentication = async () => {
@@ -73,7 +73,6 @@ const validateSurvey = async (responseInput: TResponseInput, workspaceId: string
error: responses.badRequestResponse(
"Survey is part of another workspace",
{
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
@@ -1,43 +1,16 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey } from "./surveys";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
const { mockDeleteSharedSurvey } = vi.hoisted(() => ({
mockDeleteSharedSurvey: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
delete: vi.fn(),
},
segment: {
delete: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: mockDeleteSharedSurvey,
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
const workspaceId = "clq5n7p1q0000m7z0h5p6g3r3";
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
const mockDeletedSurveyAppPrivateSegment = {
id: surveyId,
workspaceId,
type: "app",
segment: { id: segmentId, isPrivate: true },
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
};
const mockDeletedSurveyLink = {
id: surveyId,
@@ -56,66 +29,20 @@ describe("deleteSurvey", () => {
vi.clearAllMocks();
});
test("should delete a link survey without a segment and revalidate caches", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any);
test("delegates survey deletion to the shared service", async () => {
mockDeleteSharedSurvey.mockResolvedValue(mockDeletedSurveyLink);
const deletedSurvey = await deleteSurvey(surveyId);
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
include: {
segment: true,
triggers: { include: { actionClass: true } },
},
});
expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
});
test("should handle PrismaClientKnownRequestError during survey deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError during segment deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Foreign key constraint failed", {
code: "P2003",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyAppPrivateSegment as any);
vi.mocked(prisma.segment.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } });
});
test("should handle generic errors during deletion", async () => {
test("rethrows shared delete service errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError);
mockDeleteSharedSurvey.mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should throw validation error for invalid surveyId", async () => {
const invalidSurveyId = "invalid-id";
const validationError = new Error("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
expect(prisma.survey.delete).not.toHaveBeenCalled();
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
});
});
@@ -1,43 +1,3 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey as deleteSharedSurvey } from "@/modules/survey/lib/surveys";
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, z.cuid2()]);
try {
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
include: {
segment: true,
triggers: {
include: {
actionClass: true,
},
},
},
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await prisma.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
}
return deletedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error({ error, surveyId }, "Error deleting survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteSurvey = async (surveyId: string) => deleteSharedSurvey(surveyId);
@@ -1,5 +1,6 @@
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
@@ -78,6 +79,12 @@ export const GET = withV1ApiWrapper({
),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Survey", params.surveyId),
};
}
return {
response: handleErrorResponse(error),
};
@@ -1,141 +0,0 @@
import * as Sentry from "@sentry/nextjs";
import { type NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
applyIPRateLimit: vi.fn(),
getWorkspaceState: vi.fn(),
resolveClientApiIds: vi.fn(),
contextualLoggerError: vi.fn(),
}));
vi.mock("@/app/api/v1/client/[workspaceId]/environment/lib/environmentState", () => ({
getWorkspaceState: mocks.getWorkspaceState,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: mocks.applyIPRateLimit,
applyRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
},
},
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.contextualLoggerError,
warn: vi.fn(),
info: vi.fn(),
})),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
SENTRY_DSN: "test-dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
};
});
const createMockRequest = (url: string, headers = new Map<string, string>()): NextRequest => {
const parsedUrl = new URL(url);
return {
method: "GET",
url,
headers: {
get: (key: string) => headers.get(key),
},
nextUrl: {
pathname: parsedUrl.pathname,
},
} as unknown as NextRequest;
};
describe("api/v2 client environment route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyIPRateLimit.mockResolvedValue(undefined);
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId: "ck12345678901234567890123" });
});
test("reports v1-backed failures as v2 and keeps the response payload unchanged", async () => {
const underlyingError = new Error("Environment load failed");
mocks.getWorkspaceState.mockRejectedValue(underlyingError);
const request = createMockRequest(
"https://api.test/api/v2/client/ck12345678901234567890123/environment",
new Map([["x-request-id", "req-v2-env"]])
);
const { GET } = await import("../../../../v1/client/[workspaceId]/environment/route");
const response = await GET(request, {
params: Promise.resolve({
workspaceId: "ck12345678901234567890123",
}),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "An error occurred while processing your request.",
details: {},
});
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
underlyingError,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
originalError: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
status: 500,
}),
}),
})
);
});
});
@@ -1,132 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createDisplay: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromWorkspaceId: vi.fn(),
reportApiError: vi.fn(),
resolveClientApiIds: vi.fn(),
}));
vi.mock("./lib/display", () => ({
createDisplay: mocks.createDisplay,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: mocks.getOrganizationIdFromWorkspaceId,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client displays route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId: environmentId });
mocks.getOrganizationIdFromWorkspaceId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
});
test("returns a v2 bad request response for malformed JSON without reporting an internal error", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "{",
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual(
expect.objectContaining({
code: "bad_request",
message: "Invalid JSON in request body",
})
);
expect(mocks.createDisplay).not.toHaveBeenCalled();
expect(mocks.reportApiError).not.toHaveBeenCalled();
});
test("reports unexpected createDisplay failures while keeping the response payload unchanged", async () => {
const underlyingError = new Error("display persistence failed");
mocks.createDisplay.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
});
test("reports unexpected contact-license lookup failures with the same generic public response", async () => {
const underlyingError = new Error("license lookup failed");
mocks.getOrganizationIdFromWorkspaceId.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
contactId: "clh123456789012345678901234",
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createDisplay).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -178,10 +178,34 @@ describe("createResponse V2", () => {
).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["surveyId", "singleUseId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
@@ -2,7 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -131,6 +131,13 @@ export const createResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
const target = (error.meta?.target as string[]) ?? [];
if (target?.includes("singleUseId")) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
}
throw new DatabaseError(error.message);
}
@@ -92,6 +92,7 @@ const mockSurvey: TSurvey = {
isCaptureIpEnabled: false,
metadata: {},
slug: null,
isAutoProgressingEnabled: true,
};
const mockResponseInput: TResponseInputV2 = {
@@ -126,7 +127,6 @@ describe("checkSurveyValidity", () => {
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Survey is part of another workspace",
{
"survey.workspaceId": "ws-2",
workspaceId: "ws-1",
},
true
@@ -20,7 +20,6 @@ export const checkSurveyValidity = async (
return responses.badRequestResponse(
"Survey is part of another workspace",
{
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
@@ -1,150 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
checkSurveyValidity: vi.fn(),
createResponseWithQuotaEvaluation: vi.fn(),
getClientIpFromHeaders: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromWorkspaceId: vi.fn(),
getSurvey: vi.fn(),
reportApiError: vi.fn(),
resolveClientApiIds: vi.fn(),
sendToPipeline: vi.fn(),
validateResponseData: vi.fn(),
}));
vi.mock("@/app/api/v2/client/[workspaceId]/responses/lib/utils", () => ({
checkSurveyValidity: mocks.checkSurveyValidity,
}));
vi.mock("./lib/response", () => ({
createResponseWithQuotaEvaluation: mocks.createResponseWithQuotaEvaluation,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mocks.sendToPipeline,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: mocks.getClientIpFromHeaders,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: mocks.getOrganizationIdFromWorkspaceId,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
validateResponseData: mocks.validateResponseData,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client responses route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId: environmentId });
mocks.checkSurveyValidity.mockResolvedValue(null);
mocks.getSurvey.mockResolvedValue({
id: surveyId,
environmentId,
blocks: [],
questions: [],
isCaptureIpEnabled: false,
});
mocks.validateResponseData.mockReturnValue(null);
mocks.getOrganizationIdFromWorkspaceId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
mocks.getClientIpFromHeaders.mockResolvedValue("127.0.0.1");
});
test("reports unexpected response creation failures while keeping the public payload generic", async () => {
const underlyingError = new Error("response persistence failed");
mocks.createResponseWithQuotaEvaluation.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
test("reports unexpected pre-persistence failures with the same generic public response", async () => {
const underlyingError = new Error("survey lookup failed");
mocks.getSurvey.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response-pre-check",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createResponseWithQuotaEvaluation).not.toHaveBeenCalled();
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,5 @@
import { UAParser } from "ua-parser-js";
import { InvalidInputError } from "@formbricks/types/errors";
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[workspaceId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
@@ -164,6 +164,10 @@ const createResponseForRequest = async ({
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message, undefined, true);
}
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,
+132
View File
@@ -9,6 +9,22 @@ const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: mockGetServerSession,
}));
@@ -25,6 +41,14 @@ vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
@@ -45,6 +69,114 @@ describe("withV3ApiWrapper", () => {
vi.clearAllMocks();
});
test("passes an audit log to the handler and queues success after the response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1", name: "Test", email: "t@example.com" },
expires: "2026-01-01",
});
const handler = vi.fn(async ({ auditLog }) => {
expect(auditLog).toEqual(
expect.objectContaining({
action: "deleted",
targetType: "survey",
userId: "user_1",
userType: "user",
status: "failure",
})
);
if (auditLog) {
auditLog.targetId = "survey_1";
auditLog.organizationId = "org_1";
auditLog.oldObject = { id: "survey_1" };
}
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_1", {
method: "DELETE",
headers: { "x-request-id": "req-audit" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_1",
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: { id: "survey_1" },
})
);
});
test("queues a failure audit log when the handler returns a non-ok response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockAuthenticateRequest.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
environmentPermissions: [],
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler: async ({ auditLog }) => {
if (auditLog) {
auditLog.targetId = "survey_2";
}
return new Response("forbidden", { status: 403 });
},
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_2", {
method: "DELETE",
headers: {
"x-request-id": "req-failure-audit",
"x-api-key": "fbk_test",
},
}),
{} as never
);
expect(response.status).toBe(403);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_2",
organizationId: "org_1",
userId: "key_1",
userType: "api",
status: "failure",
eventId: "req-failure-audit",
})
);
});
test("uses session auth first in both mode and injects request id into plain responses", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
+76 -2
View File
@@ -4,10 +4,13 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
import {
type InvalidParam,
problemBadRequest,
@@ -15,7 +18,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3Authentication } from "./types";
import type { TV3AuditLog, TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
@@ -38,6 +41,7 @@ export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unkn
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
auditLog?: TV3AuditLog;
parsedInput: TParsedInput;
requestId: string;
instance: string;
@@ -48,6 +52,8 @@ export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = u
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
action?: TAuditAction;
targetType?: TAuditTarget;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
@@ -293,10 +299,61 @@ async function applyV3RateLimitOrRespond(params: {
return null;
}
function buildV3AuditLog(
authentication: TV3Authentication,
action?: TAuditAction,
targetType?: TAuditTarget,
apiUrl?: string
): TV3AuditLog | undefined {
if (!authentication || !action || !targetType || !apiUrl) {
return undefined;
}
const auditLog = buildAuditLogBaseObject(action, targetType, apiUrl);
if ("user" in authentication && authentication.user?.id) {
auditLog.userId = authentication.user.id;
auditLog.userType = "user";
} else if ("apiKeyId" in authentication) {
auditLog.userId = authentication.apiKeyId;
auditLog.userType = "api";
auditLog.organizationId = authentication.organizationId;
}
return auditLog;
}
async function queueV3AuditLog(
auditLog: TV3AuditLog | undefined,
requestId: string,
log: ReturnType<typeof logger.withContext>
): Promise<void> {
if (!auditLog) {
return;
}
try {
await queueAuditEvent({
...auditLog,
...(auditLog.status === "failure" ? { eventId: auditLog.eventId ?? requestId } : {}),
});
} catch (error) {
log.error({ error }, "Failed to queue V3 audit event");
}
}
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
const {
auth = "both",
schemas,
rateLimit = true,
customRateLimitConfig,
handler,
action,
targetType,
} = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
@@ -306,6 +363,7 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
method: req.method,
path: instance,
});
let auditLog: TV3AuditLog | undefined;
try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
@@ -331,17 +389,33 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
return rateLimitResponse;
}
auditLog = buildV3AuditLog(authResult.authentication, action, targetType, req.url);
const response = await handler({
req,
props,
authentication: authResult.authentication,
auditLog,
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
});
if (auditLog) {
if (response.ok) {
auditLog.status = "success";
} else {
auditLog.eventId = requestId;
}
}
await queueV3AuditLog(auditLog, requestId, log);
return ensureRequestIdHeader(response, requestId);
} catch (error) {
if (auditLog) {
auditLog.eventId = requestId;
await queueV3AuditLog(auditLog, requestId, log);
}
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
+25
View File
@@ -7,6 +7,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
successListResponse,
successResponse,
} from "./response";
describe("v3 problem responses", () => {
@@ -93,3 +94,27 @@ describe("successListResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});
describe("successResponse", () => {
test("wraps the payload in a data envelope", async () => {
const res = successResponse({ id: "survey_1" }, { requestId: "req-success" });
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-success");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: { id: "survey_1" },
});
});
test("allows custom status and cache headers", async () => {
const res = successResponse(
{ ok: true },
{
cache: "private, max-age=60",
status: 202,
}
);
expect(res.status).toBe(202);
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
+24
View File
@@ -147,3 +147,27 @@ export function successListResponse<T, TMeta extends Record<string, unknown>>(
}
return Response.json({ data, meta }, { status: 200, headers });
}
export function successResponse<T>(
data: T,
options?: { requestId?: string; cache?: string; status?: number }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json(
{
data,
},
{
status: options?.status ?? 200,
headers,
}
);
}
+2
View File
@@ -1,4 +1,6 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import type { TApiAuditLog } from "@/app/lib/api/with-api-logging";
export type TV3Authentication = TAuthenticationApiKey | Session | null;
export type TV3AuditLog = TApiAuditLog;
@@ -0,0 +1,318 @@
import { ApiKeyPermission } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const workspaceId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) {
headers["x-request-id"] = requestId;
}
return new NextRequest(url, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
workspacePermissions: [
{
workspaceId,
workspaceName: "W",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
workspaceId: workspaceId,
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
workspaceId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
workspaceId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
});
@@ -0,0 +1,72 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: z.object({
surveyId: z.cuid2(),
}),
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const survey = await getSurvey(surveyId);
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
if (auditLog) {
auditLog.targetId = survey.id;
auditLog.organizationId = authResult.organizationId;
auditLog.oldObject = survey;
}
const deletedSurvey = await deleteSurvey(surveyId);
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey delete unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
+1 -1
View File
@@ -314,8 +314,8 @@ describe("GET /api/v3/surveys", () => {
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("ws_1");
});
@@ -93,17 +93,17 @@ describe("parseAndValidateJsonBody", () => {
request,
schema: z.object({
finished: z.boolean(),
environmentId: z.string(),
workspaceId: z.string(),
}),
buildInput: (jsonInput) => ({
...(jsonInput as Record<string, unknown>),
environmentId: "env_123",
workspaceId: "ws_123",
}),
});
expect(result).toEqual({
data: {
environmentId: "env_123",
workspaceId: "ws_123",
finished: true,
},
});
+50
View File
@@ -339,6 +339,56 @@ describe("API Response Utilities", () => {
});
});
describe("conflictResponse", () => {
test("should return a conflict response", () => {
const message = "Resource already exists";
const details = { field: "singleUseId" };
const response = responses.conflictResponse(message, details);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details: {},
});
});
});
test("should include CORS headers when cors is true", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message, undefined, true);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
});
test("should use custom cache control header when provided", () => {
const message = "Resource already exists";
const customCache = "no-cache";
const response = responses.conflictResponse(message, undefined, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("tooManyRequestsResponse", () => {
test("should return a too many requests response", () => {
const message = "Rate limit exceeded";
+27 -1
View File
@@ -16,7 +16,8 @@ interface ApiErrorResponse {
| "method_not_allowed"
| "not_authenticated"
| "forbidden"
| "too_many_requests";
| "too_many_requests"
| "conflict";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -236,6 +237,30 @@ const internalServerErrorResponse = (
);
};
const conflictResponse = (
message: string,
details?: { [key: string]: string },
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "conflict",
message,
details: details || {},
} as ApiErrorResponse,
{
status: 409,
headers,
}
);
};
const tooManyRequestsResponse = (
message: string,
cors: boolean = false,
@@ -270,4 +295,5 @@ export const responses = {
successResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
};
+140 -19
View File
@@ -3,9 +3,16 @@ import { NextRequest } from "next/server";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import type { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { responses } from "./response";
const AuthMethod = {
ApiKey: "apiKey" as AuthenticationMethod,
Session: "session" as AuthenticationMethod,
Both: "both" as AuthenticationMethod,
None: "none" as AuthenticationMethod,
} as const;
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
__esModule: true,
queueAuditEvent: vi.fn(),
@@ -122,7 +129,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -198,7 +205,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -244,7 +251,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -318,7 +325,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -370,7 +377,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -425,7 +432,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -449,7 +456,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
@@ -473,6 +480,90 @@ describe("withV1ApiWrapper", () => {
});
});
test("skips app rate limiting for Envoy-covered client routes", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ method: "POST", url: "/api/v1/client/env_123/storage" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyIPRateLimit).not.toHaveBeenCalled();
});
test("keeps app rate limiting for uncovered client routes", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ method: "GET", url: "/api/v2/client/env_123/environment" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("keeps app rate limiting for uncovered verbs on otherwise covered client paths", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ method: "PATCH", url: "/api/v1/client/env_123/environment" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
@@ -481,7 +572,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
@@ -504,7 +595,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Session,
authenticationMethod: AuthMethod.Session,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue(null);
@@ -528,7 +619,36 @@ describe("withV1ApiWrapper", () => {
expect(mockContextualLoggerError).toHaveBeenCalled();
});
test("handles rate limiting errors", async () => {
test("keeps app rate limiting for uncovered session-authenticated management routes", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.Both,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } } as any);
const rateLimitError = new Error("Rate limit exceeded");
rateLimitError.message = "Rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ method: "POST", url: "https://api.test/api/v1/management/storage" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const customRateLimitConfig = { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" };
const wrapped = withV1ApiWrapper({ handler, customRateLimitConfig });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
expect(applyRateLimit).toHaveBeenCalledWith(customRateLimitConfig, "user-1");
});
test("skips app rate limiting for Envoy-covered API-key management routes", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
@@ -538,21 +658,22 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const rateLimitError = new Error("Rate limit exceeded");
rateLimitError.message = "Rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
vi.mocked(applyRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const handler = vi.fn();
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
expect(res.status).toBe(200);
expect(applyRateLimit).not.toHaveBeenCalled();
});
test("skips audit log creation when no action/targetType provided", async () => {
@@ -566,7 +687,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
+44 -6
View File
@@ -13,6 +13,10 @@ import {
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
TEnvoyRateLimitAuthType,
isRouteRateLimitedByEnvoy,
} from "@/modules/core/rate-limit/envoy-rate-limit-coverage";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
@@ -61,29 +65,58 @@ const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): P
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
};
const getEnvoyRateLimitAuthType = (
authentication: TApiV1Authentication
): TEnvoyRateLimitAuthType | "unknown" => {
if (!authentication) {
return "none";
}
if ("user" in authentication) {
return "session";
}
if ("apiKeyId" in authentication) {
return "apiKey";
}
return "unknown";
};
/**
* Handle rate limiting based on authentication and API type
*/
const handleRateLimiting = async (
req: NextRequest,
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum,
customRateLimitConfig?: TRateLimitConfig
): Promise<Response | null> => {
const authType = getEnvoyRateLimitAuthType(authentication);
if (authType === "unknown") {
logger.error({ authentication }, "Unknown authentication type");
return responses.internalServerErrorResponse("Invalid authentication configuration");
}
const isEnvoyManagedRateLimit = isRouteRateLimitedByEnvoy({
pathname: req.nextUrl.pathname,
method: req.method,
authType,
});
try {
if (authentication) {
if (authentication && !isEnvoyManagedRateLimit) {
if ("user" in authentication) {
// Session-based authentication for integration routes
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.user.id);
} else if ("apiKeyId" in authentication) {
// API key authentication for general routes
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.apiKeyId);
} else {
logger.error({ authentication }, "Unknown authentication type");
return responses.internalServerErrorResponse("Invalid authentication configuration");
}
}
if (routeType === ApiV1RouteTypeEnum.Client) {
if (routeType === ApiV1RouteTypeEnum.Client && !isEnvoyManagedRateLimit) {
await applyClientRateLimit(customRateLimitConfig);
}
} catch (error) {
@@ -286,7 +319,12 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
const rateLimitResponse = await handleRateLimiting(
req,
authentication,
routeType,
customRateLimitConfig
);
if (rateLimitResponse) return rateLimitResponse;
}
+64 -15
View File
@@ -3,7 +3,9 @@ import type { TFunction } from "i18next";
import type { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import type {
TSurveyCTAElement,
TSurveyCesElement,
TSurveyConsentElement,
TSurveyCsatElement,
TSurveyElement,
TSurveyMultipleChoiceElement,
TSurveyNPSElement,
@@ -96,7 +98,8 @@ export const buildOpenTextElement = ({
};
};
export const buildRatingElement = ({
const buildScaleElement = <T extends TSurveyRatingElement | TSurveyCsatElement | TSurveyCesElement>({
type,
id,
headline,
subheader,
@@ -107,6 +110,32 @@ export const buildRatingElement = ({
required,
isColorCodingEnabled = false,
}: {
type: T["type"];
id?: string;
headline: string;
scale: T["scale"];
range: T["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): T => {
return {
id: id ?? createId(),
type,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
} as T;
};
export const buildRatingElement = (params: {
id?: string;
headline: string;
scale: TSurveyRatingElement["scale"];
@@ -116,20 +145,8 @@ export const buildRatingElement = ({
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyRatingElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.Rating,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
};
};
}): TSurveyRatingElement =>
buildScaleElement<TSurveyRatingElement>({ ...params, type: TSurveyElementTypeEnum.Rating });
export const buildConsentElement = ({
id,
@@ -212,6 +229,38 @@ export const buildNPSElement = ({
};
};
export const buildCsatElement = ({
scale = "smiley",
...params
}: {
id?: string;
headline: string;
scale?: TSurveyCsatElement["scale"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyCsatElement =>
buildScaleElement<TSurveyCsatElement>({ ...params, scale, range: 5, type: TSurveyElementTypeEnum.CSAT });
export const buildCesElement = ({
scale = "number",
range = 5,
...params
}: {
id?: string;
headline: string;
scale?: TSurveyCesElement["scale"];
range?: TSurveyCesElement["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyCesElement =>
buildScaleElement<TSurveyCesElement>({ ...params, scale, range, type: TSurveyElementTypeEnum.CES });
// Helper function to create block-level jump logic based on operator
export const createBlockJumpLogic = (
sourceElementId: string,
+6
View File
@@ -30,6 +30,8 @@ const conditionOptions: Record<string, string[]> = {
multipleChoiceMulti: ["Includes all", "Includes either"],
nps: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped", "Includes either"],
rating: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
csat: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
ces: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
cta: ["is"],
tags: ["is"],
languages: ["Equals", "Not equals"],
@@ -45,6 +47,8 @@ const filterOptions: Record<string, string[]> = {
openText: ["Filled out", "Skipped"],
rating: ["1", "2", "3", "4", "5"],
nps: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
csat: ["1", "2", "3", "4", "5"],
ces: ["1", "2", "3", "4", "5", "6", "7"],
cta: ["Clicked", "Dismissed"],
tags: ["Applied", "Not applied"],
consent: ["Accepted", "Dismissed"],
@@ -436,6 +440,8 @@ const processElementFilters = (
break;
case TSurveyElementTypeEnum.NPS:
case TSurveyElementTypeEnum.Rating:
case TSurveyElementTypeEnum.CSAT:
case TSurveyElementTypeEnum.CES:
processNPSRatingFilter(filterType, elementId, filters);
break;
case TSurveyElementTypeEnum.CTA:
+15 -14
View File
@@ -7,7 +7,9 @@ import type { TTemplate } from "@formbricks/types/templates";
import {
buildBlock,
buildCTAElement,
buildCesElement,
buildConsentElement,
buildCsatElement,
buildMultipleChoiceElement,
buildNPSElement,
buildOpenTextElement,
@@ -971,13 +973,13 @@ const improveTrialConversion = (t: TFunction): TTemplate => {
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.improve_trial_conversion_question_2_headline"),
headline: t("templates.improve_trial_conversion_question_3_headline"),
required: true,
inputType: "text",
}),
],
logic: [createBlockJumpLogic(reusableElementIds[2], block6Id, "isSubmitted")],
buttonLabel: t("templates.improve_trial_conversion_question_2_button_label"),
buttonLabel: t("templates.improve_trial_conversion_question_3_button_label"),
t,
}),
buildBlock({
@@ -1319,8 +1321,7 @@ const employeeSatisfaction = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_1"),
elements: [
buildRatingElement({
range: 5,
buildCsatElement({
scale: "star",
headline: t("templates.employee_satisfaction_question_1_headline"),
required: true,
@@ -1647,14 +1648,14 @@ const identifyCustomerGoals = (t: TFunction): TTemplate => {
elements: [
buildMultipleChoiceElement({
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: "What's your primary goal for using $[workspaceName]?",
headline: t("templates.identify_customer_goals_question_1_headline"),
required: true,
shuffleOption: "none",
choices: [
"Understand my user base deeply",
"Identify upselling opportunities",
"Build the best possible product",
"Rule the world to make everyone breakfast brussels sprouts.",
t("templates.identify_customer_goals_question_1_choice_1"),
t("templates.identify_customer_goals_question_1_choice_2"),
t("templates.identify_customer_goals_question_1_choice_3"),
t("templates.identify_customer_goals_question_1_choice_4"),
],
}),
],
@@ -2723,7 +2724,7 @@ const customerEffortScore = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_1"),
elements: [
buildRatingElement({
buildCesElement({
range: 5,
scale: "number",
headline: t("templates.customer_effort_score_question_1_headline"),
@@ -3828,9 +3829,8 @@ const improveNewsletterContent = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_1"),
elements: [
buildRatingElement({
buildCsatElement({
id: reusableElementIds[0],
range: 5,
scale: "smiley",
headline: t("templates.improve_newsletter_content_question_1_headline"),
required: true,
@@ -4409,8 +4409,7 @@ const longTermRetentionCheckIn = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_9"),
elements: [
buildRatingElement({
range: 5,
buildCsatElement({
scale: "smiley",
headline: t("templates.long_term_retention_check_in_question_9_headline"),
required: true,
@@ -4825,6 +4824,8 @@ export const previewSurvey = (workspaceName: string, t: TFunction): TSurvey => {
workspaceId: "cmnh38nzx00003b6r3svd9pv2",
createdBy: "cltwumfbz0000echxysz6ptvq",
status: "inProgress" as const,
publishOn: null,
closeOn: null,
welcomeCard: {
enabled: false,
headline: createI18nString(t("templates.preview_survey_welcome_card_headline"), []),
@@ -121,13 +121,10 @@ export const DELETE = async (
: responses.notAuthenticatedResponse();
}
if (authResult.ok) {
// Rate limiting for apiKey DELETE is enforced by Envoy in v5 — see envoy-rate-limit-coverage.ts
if (authResult.ok && authResult.data.authType !== "apiKey") {
try {
if (authResult.data.authType === "apiKey") {
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.apiKeyId);
} else {
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
}
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
} catch (error) {
return responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Unknown error occurred"
@@ -142,20 +139,20 @@ export const DELETE = async (
idParam
);
const isSuccess = deleteResult.ok;
if (!deleteResult.ok) {
const { error } = deleteResult;
if (!isSuccess) {
logger.error({ error: deleteResult.error }, "Error deleting file");
logger.error({ error }, "Error deleting file");
await logFileDeletion({
failureReason: deleteResult.error.code,
failureReason: error.code,
accessType,
userId: session?.user?.id,
workspaceId: resolved.workspaceId,
apiUrl: request.url,
});
const errorResponse = getErrorResponseFromStorageError(deleteResult.error, { fileName });
const errorResponse = getErrorResponseFromStorageError(error, { fileName });
return errorResponse;
}

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