Compare commits

..

253 Commits

Author SHA1 Message Date
Dhruwang Jariwala f4ca7c46ef fix: add Hub and Cube env vars to Docker build secrets (#7950) 2026-05-07 17:22:05 +05:30
Dhruwang c252d8c4c9 fix: update tests for required Cube and Hub env vars
Tests now expect validation failures when CUBEJS_API_URL, CUBEJS_API_SECRET,
or HUB_API_KEY are missing, and all test env helpers provide HUB_API_KEY.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 17:14:01 +05:30
Dhruwang 2bec3b040d fix: remove unused ZOptionalUrl variable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 17:06:23 +05:30
Dhruwang 3c49b33dad feat: make HUB_API_KEY required and add to Docker build secrets
Hub is mandatory in v5, so HUB_API_KEY should fail fast at startup
if not configured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:56:55 +05:30
Dhruwang 0f2f3d337e fix: restore CUBEJS_JWT_AUDIENCE and CUBEJS_JWT_ISSUER in env schema
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:48:57 +05:30
Dhruwang 4d1df795ad feat: make CUBEJS_API_SECRET and CUBEJS_API_URL required
Makes Cube env vars mandatory in env.ts (per PR #7913) and adds them
as Docker build secrets with fallback values, following the same pattern
as DATABASE_URL, REDIS_URL, and HUB_API_URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:45:46 +05:30
Bhagya Amarasinghe 3ce2998d0d feat(helm): add Hub worker and embeddings runtime (#7945) 2026-05-07 16:35:32 +05:30
Bhagya Amarasinghe b9a6520e10 fix(helm): address embeddings review feedback 2026-05-07 16:21:42 +05:30
Dhruwang 55bb9a525e fix: use secrets.DUMMY_HUB_API_URL instead of hardcoded value
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:20:48 +05:30
Dhruwang 11055f812e fix: add HUB_API_URL to Docker build secrets
HUB_API_URL is required by the Zod env validation at build time but was
not provided as a Docker secret, causing the release build to fail.

Adds HUB_API_URL with a dummy fallback (http://localhost:4000) to the
build pipeline, following the same pattern as DATABASE_URL/REDIS_URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:18:35 +05:30
Anshuman Pandey ecf3aacca3 fix: removes auto feedback directory linking with workspaces (#7947) 2026-05-07 13:46:25 +04:00
Dhruwang Jariwala a0f3d2a651 chore: upgrade Hub to 0.3.0 and SDK to 0.5.0 (#7948) 2026-05-07 14:59:11 +05:30
Dhruwang 16bbd7a447 chore: upgrade Hub to 0.3.0 and SDK to 0.5.0
Hub 0.3.0 renames the `user_identifier` API field to `user_id` (breaking
change). This commit bumps the Hub Docker image, upgrades the
@formbricks/hub TypeScript SDK from 0.4.3 to 0.5.0, and renames every
`user_identifier` reference in Zod schemas, server actions, transform
pipeline, form components, CubeJS schema, connector types, and seed data
to match the new API contract.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 14:31:09 +05:30
Bhagya Amarasinghe a276aa6d34 fix(helm): default embeddings model to gte multilingual 2026-05-07 13:46:29 +05:30
Dhruwang Jariwala 6fb9cf28b1 fix: add cursor-based pagination and fix refresh for feedback records (#7935)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-07 10:13:59 +04:00
Dhruwang Jariwala 8c47cdba73 chore: drop explicit feedback directory grants, use implicit auth (#7941) 2026-05-07 10:24:30 +05:30
Bhagya Amarasinghe e6b6f5e6d3 feat(helm): add Hub worker and embeddings runtime 2026-05-07 01:45:45 +05:30
pandeymangg 6218153351 fixes tests 2026-05-06 16:33:03 +05:30
pandeymangg 9ef4be270b fix: removes feedback directory auth from api keys 2026-05-06 16:29:06 +05:30
Dhruwang Jariwala ed42df34c4 feat(ai): support Vertex AI ADC credentials (#7938) 2026-05-06 12:37:24 +05:30
Dhruwang Jariwala 8c8ff8e396 feat: gate AI chart generation behind all AI checks (#7937) 2026-05-06 10:09:49 +05:30
Dhruwang 72cf2d6a50 test: add coverage for getAIDataAnalysisUnavailableReason 2026-05-05 18:06:02 +05:30
Bhagya Amarasinghe c5d629ef25 feat(ai): support Vertex AI ADC credentials 2026-05-05 18:04:30 +05:30
Dhruwang 71cb8bdff5 refactor: extract getAIDataAnalysisUnavailableReason to shared utility
Move duplicated function to @/lib/ai/service and export TAIUnavailableReason
type. Remove local copies from charts-list-page and dashboard-detail-page.
2026-05-05 17:40:42 +05:30
Dhruwang 850fb8acc3 feat: gate AI chart generation behind all 3 AI checks
- Server-side: Replace hardcoded OpenAI with provider-agnostic `getAiModel(env)` and enforce
  `assertOrganizationAIConfigured(organizationId, "dataAnalysis")` which validates license
  entitlement, org-level toggle, and instance configuration
- Client-side: Instead of hiding AI section when unavailable, show it disabled with a tooltip
  explaining the reason (not in plan / not enabled / instance not configured), following the
  same pattern as AI translate
- Thread `isAIAvailable` and `aiUnavailableReason` through the component chain from server
  pages down to `AIQuerySection`
- Update test mocks to match new provider-agnostic AI imports
2026-05-05 17:21:22 +05:30
Dhruwang Jariwala 94c9e8fcf1 feat: gate Unify Feedback, FRDs, Dashboards behind license (#7924) 2026-05-05 17:15:14 +05:30
pandeymangg 49a8c8c686 adds nav links 2026-05-05 16:33:35 +05:30
pandeymangg 2832831db1 chore: merge with epic/v5 2026-05-05 16:21:34 +05:30
pandeymangg b5e6567194 fixes 2026-05-05 16:13:12 +05:30
Dhruwang Jariwala 86d3f2fae1 chore: hardening cube tenant isolation (#7920) 2026-05-05 16:03:11 +05:30
pandeymangg 62d09f6a8f chore: merge with epic/v5 2026-05-05 15:14:53 +05:30
Johannes 74dd778630 feat: similar feedback preview (#7917)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-05 13:28:08 +04:00
Tiago Farto 7ac99c0840 chore: update 2026-05-05 08:50:03 +00:00
Tiago Farto dde0f8d32c Merge branch 'epic/v5' into chore/harden-cube-tenant-isolation 2026-05-05 08:49:36 +00:00
Tiago Farto bcd3c91075 chore: address PR concerns 2026-05-05 08:39:56 +00:00
Bhagya Amarasinghe f376c620ab docs: align self-hosting docs for Formbricks v5 (#7906) 2026-05-05 12:52:18 +05:30
Bhagya Amarasinghe 4865a78338 docs: align v5 cube deployment guidance 2026-05-05 12:50:41 +05:30
Bhagya Amarasinghe a7c8e1acf9 docs: add MinIO to RustFS migration pointer 2026-05-05 12:47:02 +05:30
Bhagya Amarasinghe e5a097e56e docs: address CodeRabbit review feedback 2026-05-05 12:47:02 +05:30
Bhagya Amarasinghe 1ddde9cac7 docs: align self-hosting docs for Formbricks v5 2026-05-05 12:47:02 +05:30
Anshuman Pandey 59f5cdfb4b fix: hub pinned at specific tag/digest (#7923) 2026-05-05 11:15:41 +04:00
pandeymangg 8431eaf9f6 chore: merge with epic/v5 2026-05-05 11:38:39 +05:30
Dhruwang Jariwala f228e8e06a chore: Rename FeedbackRecordDirectory to FeedbackDirectory (#7925) 2026-05-05 09:15:20 +04:00
Dhruwang Jariwala 5e6ab81cb1 fix: migrate feedback-sources page to unified settings navigation (#7928) 2026-05-05 10:09:30 +05:30
Tiago Farto 1417a5a654 chore: restore document 2026-05-04 13:05:53 +00:00
Tiago Farto f8ae92b3be chore: remove doc 2026-05-04 13:04:37 +00:00
Dhruwang 1bc3f79f30 fix: translations 2026-05-04 18:25:11 +05:30
Dhruwang 7151dd5234 fix: migrate feedback-sources page to unified settings navigation
The feedback-sources page was still using the old WorkspaceConfigNavigation
(secondary tabs) instead of the new unified settings sidebar introduced in
#7904. This caused an inconsistent navigation experience.

Changes:
- Create new route at /settings/workspace/feedback-sources
- Add feedback-sources entry to SettingsSidebarContent
- Remove old WorkspaceConfigNavigation from ConnectorsSection
- Redirect old /feedback-sources route to new settings path
- Update all stale /feedback-sources links across the codebase
2026-05-04 18:20:54 +05:30
Dhruwang Jariwala 086315ce33 feat: unify settings UI with shared sidebar navigation (#7904) 2026-05-04 17:37:53 +05:30
Tiago Farto e01b4311ca chore: cleaned documentation duplication 2026-05-04 11:52:41 +00:00
Tiago Farto dd757394af fix: make bundled cube optional 2026-05-04 11:03:37 +00:00
Dhruwang 507f80f9b0 fix: update stale settings routes to match new /settings/{organization,workspace}/ structure
All internal links (billing, enterprise, general, api-keys, feedback-record-directories,
integrations) now point to their correct nested paths under /settings/organization/ or
/settings/workspace/. Also adds feedback-record-directories to the new sidebar nav with
the member visibility rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 16:31:25 +05:30
pandeymangg 8562232280 adds tests 2026-05-04 13:27:22 +05:30
pandeymangg 1234e6685a fixes feedback 2026-05-04 13:15:37 +05:30
pandeymangg 40a5e8ea6a fixes tests and i18n validation 2026-05-04 13:12:38 +05:30
pandeymangg 319a76a70d moves connectors, dashboards and frd to ee 2026-05-04 12:55:34 +05:30
Tiago Farto 2abf8e1d8c fix: log rejected cube tenant queries 2026-04-30 16:52:41 +00:00
Tiago Farto a985dc698b refactor: simplify cube query filter traversal 2026-04-30 16:50:55 +00:00
Tiago Farto 7b59a6300e fix: address cube tenant isolation review 2026-04-30 16:34:09 +00:00
Tiago Farto bf8b4079fd test: isolate cube env config test 2026-04-30 16:20:29 +00:00
Tiago Farto 5704bfbc03 chore: hardening cube tenant isolation 2026-04-30 16:00:08 +00:00
Dhruwang 0920ccf2c3 fix: remove unused isBilling prop and stale translation keys
- Remove isBilling from WorkspaceBreadcrumb/WorkspaceAndOrgSwitch prop chain
- Remove unused common.organization_settings and common.unify translation keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 18:24:32 +05:30
Dhruwang db0c9e7c55 fix: update E2E action tests to wait for user-actions URL
The tests were waiting for a redirect to app-connection that no longer
exists — user-actions is now a standalone page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 18:09:45 +05:30
Dhruwang ef87d899b9 fix: simplify dropdown menus and fix Connect Your App icon
- Replace individual settings items in workspace/org dropdowns with single Settings link
- Change Connect Your App icon from ListChecksIcon to UnplugIcon
- Remove unused code (isActiveOrganizationSetting, isActiveWorkspaceSetting, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 18:06:19 +05:30
Bhagya Amarasinghe ea92ef9fce feat: add FeedbackRecords Envoy gateway (#7818) 2026-04-30 17:17:05 +05:30
pandeymangg 778fc2acf1 fix 2026-04-30 16:36:09 +05:30
Dhruwang 2ffef36c89 fix: update E2E tests for user-actions route and Teams heading ambiguity
- action.spec.ts: navigate to user-actions page instead of app-connection
- organization.spec.ts: use level:1 to disambiguate "Teams" heading

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:30:22 +05:30
pandeymangg 1d6bda74df removes route test 2026-04-30 16:26:47 +05:30
pandeymangg 12ff0b7c0e sonar issue fix 2026-04-30 16:19:11 +05:30
Dhruwang fa1079bac1 fix: update E2E tests for renamed settings labels
- "Look & Feel" comments → "Appearance"
- "Members & Teams" heading assertion → "Teams"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:07:18 +05:30
Dhruwang 1403f0bb01 fix: pass missing isBilling prop to WorkspaceBreadcrumb
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:06:34 +05:30
Dhruwang c79553633f fix: use export...from to re-export default in user-actions routes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:04:04 +05:30
Bhagya Amarasinghe f16fb3b62f fix: restore required feedback record list params 2026-04-30 15:59:45 +05:30
Dhruwang 7dfc7f4825 docs: update references to renamed settings labels
- "Configuration" → "Settings"
- "Look & Feel" → "Appearance"
- "Website & App Connection" → "Connect Your App" / "User Actions"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 15:46:47 +05:30
Dhruwang 1ecc9f1722 fix: restore settings sidebar, rename labels, fix SonarQube issues, and extract user-actions page
- Restore settings sidebar in MainNavigation (lost during epic/v5 merge)
- Rename "Configuration" to "Settings", "Look & Feel" to "Appearance", etc.
- Fix SonarQube issues: duplicate class, regex injection, nested ternary, inline arrow functions
- Extract User Actions from Connect Your App into its own settings page
- Update all i18n translation keys across locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 15:41:50 +05:30
Dhruwang 7d1c02b54b Merge remote-tracking branch 'origin/epic/v5' into feat/settings-cleanup-v5 2026-04-30 14:56:35 +05:30
Bhagya Amarasinghe f2c452d7f9 feat: make cubejs mandatory for xm suite v5 (#7913) 2026-04-30 14:34:50 +05:30
Bhagya Amarasinghe afcfbb7a3a fix: address cube review follow-ups 2026-04-30 14:17:54 +05:30
Bhagya Amarasinghe 7f8c9dcbb8 chore: merge epic/v5 into feedback records gateway 2026-04-30 01:22:24 +05:30
Bhagya Amarasinghe 3998e4da31 fix: resolve sonar quality gate warning 2026-04-30 00:59:25 +05:30
Bhagya Amarasinghe 48086faffc fix: address cube review feedback 2026-04-30 00:39:49 +05:30
Bhagya Amarasinghe 38a0d7c810 Merge remote-tracking branch 'origin/epic/v5' into bhagya/eng-765-make-cubejs-mandatory-for-xm-suite-v5 2026-04-30 00:32:05 +05:30
Bhagya Amarasinghe b17bb88daa fix: require cube env vars in app config 2026-04-30 00:30:11 +05:30
Anshuman Pandey f59e9f13ec feat: refresh analysis charts and dashboard feedback gating (#7915)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-29 16:29:14 +04:00
Anshuman Pandey 5169dec510 feat: wire workspace settings to feedback record directories (#7910)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 14:49:50 +04:00
Bhagya Amarasinghe 0df16f6f0c feat: make cubejs mandatory for xm suite v5 2026-04-29 16:08:24 +05:30
Anshuman Pandey 8442dedf9c fix: removes project references (#7907) 2026-04-29 14:17:42 +04:00
Dhruwang 22c27c5ebb fix: remove unused params prop from notifications page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 15:27:50 +05:30
Dhruwang 6638dceb04 feat: disable settings for billing role, hide back button, add org switcher to landing sidebar
- Disable all workspace and select org settings items for billing-role users
- Hide the top bar (back button) for billing users in settings mode
- Add organization switcher with lazy-loaded org list to landing sidebar
- Pass isMultiOrgEnabled to landing sidebar for create-org option

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 15:24:42 +05:30
Dhruwang 8558121e46 feat: enhance SettingsSidebarContent with tooltip and popover for disabled items
- Added Tooltip and Popover components to provide user feedback for disabled navigation items.
- Implemented conditional rendering of tooltips and popovers based on item state.
- Introduced a disabledMessage prop to display appropriate messages for unauthorized actions.
2026-04-29 14:46:48 +05:30
Dhruwang f1279d51e5 fix: transaltions 2026-04-29 14:42:48 +05:30
Dhruwang 926706be9d fix: merge epic/v5, fix stale integration URLs and settings workspace switcher
- Resolve merge conflict in create-connector-modal (keep NoFeedbackRecordDirectoryAlert)
- Fix GoBackButton URLs in slack, google-sheets, airtable integration pages to use /settings/workspace/integrations path
- Fix connectHref values in integrations page (webhooks, google-sheets, airtable, slack, notion, JS SDK)
- Fix handleWorkspaceChange to stay in settings mode when switching workspace from settings sidebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 14:36:06 +05:30
Dhruwang 85b456e619 fix: navigate to surveys via URL in multi-language e2e test
The settings sidebar replaces the main nav, so the "Surveys" link is
not visible when on a settings page. Use direct URL navigation instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 13:46:34 +05:30
Bhagya Amarasinghe 3bac488a29 fix: address gateway review follow-ups 2026-04-29 12:27:41 +05:30
Johannes fbe2a31133 refactor: align connector enum with formbricks_survey (#7825)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-29 10:57:20 +04:00
Bhagya Amarasinghe 79d618f77c refactor: generalize gateway token minting 2026-04-29 12:16:08 +05:30
Anshuman Pandey 89eb04f813 fix: adds submission id to csv connector (#7898) 2026-04-29 10:36:16 +04:00
Dhruwang 8a2b349329 feat: unify settings under /workspaces/[id]/settings with shared sidebar navigation
Consolidate all settings (Account, Organization, Workspace) under a unified
URL structure with a shared sidebar that replaces the main navigation when
in settings mode. Remove horizontal nav bars, old dropdown-based navigation
patterns, and route group layouts in favor of real URL segments.

- Move workspace settings from /(workspace)/ to /settings/workspace/
- Move org settings from /settings/(organization)/ to /settings/organization/
- Move account settings from /settings/(account)/ to /settings/account/
- Add SettingsSidebarContent with inline workspace/org switchers
- Replace main sidebar with settings nav when pathname includes /settings
- Update all page headings to match sidebar nav labels
- Update e2e tests for new URL structure and navigation patterns
- Remove unused translation keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 18:26:58 +05:30
Dhruwang Jariwala a862b739f7 fix: consistent enabled/disabled wording for connector status (#7897) 2026-04-28 15:11:44 +05:30
Dhruwang 4e5df85538 fix: make pipeline dispatch fire-and-forget in management responses route
Pipeline errors (snapshot loading or dispatch) should not prevent the
201 response from being returned. Dispatch pipeline events without
awaiting so the response is returned immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 14:31:24 +05:30
Dhruwang 727b349086 fix: resolve pre-existing build errors on epic/v5
- Add optional chaining for organization.billing in response pipeline
- Add missing feedbackRecordDirectoryId to Chart seed data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 13:47:20 +05:30
Dhruwang f75db6b1d0 fix: translations 2026-04-28 12:39:12 +05:30
Dhruwang 7ffca53577 fix: use consistent enabled/disabled wording for connector status badges
The dropdown actions say "Enable"/"Disable" but the status badges showed
"In Progress"/"Paused". Now both use "Enabled"/"Disabled" for consistency.

Resolves ENG-769

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 12:24:49 +05:30
Dhruwang Jariwala 25614b23fc chore: remove legacy styling fields (questionColor, inputColor) (#7783) 2026-04-28 11:22:03 +05:30
Johannes 016e14d0f1 fix: (Depr Env QA) Surface legacy env var on Connection page (#7773) 2026-04-27 14:20:25 +00:00
Bhagya Amarasinghe be80db8418 fix: address envoy auth review findings 2026-04-27 19:31:43 +05:30
Bhagya Amarasinghe bcc3789ce8 refactor: generalize envoy auth dispatch 2026-04-27 18:31:58 +05:30
Dhruwang 5e76ebdfc1 fix: treat JSON null as absent in legacy styling migration
The `?` operator only checks key existence — if the form layer saved
`{"elementHeadlineColor": null}` (JSON null = "use default"), the
migration skipped the copy and then removed the legacy key, losing
the color value. Switch to COALESCE(styling->'field', 'null'::jsonb)
= 'null'::jsonb which catches both missing keys (SQL NULL) and JSON
null values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 17:07:05 +05:30
Bhagya Amarasinghe 150f256721 fix: decouple pipeline from response ingestion (#7651) 2026-04-27 12:59:27 +05:30
pandeymangg da7971328c little cleanup 2026-04-27 12:58:42 +05:30
Bhagya Amarasinghe a6cd56b196 fix: migrate response pipeline to BullMQ (#7651) 2026-04-27 12:58:42 +05:30
pandeymangg 7c81cf119e adds test for schedulePipelineDrain when env vars are not set 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 8d29b24352 fix: address latest pipeline review comments (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe a1ae849496 fix: address CodeRabbit pipeline findings (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 4d0a686e89 fix: address pipeline PR checks (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 364915e4c8 fix: decouple pipeline from response ingestion (#1487) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe ada2518d0c fix: address feedback records gateway build failures 2026-04-24 17:30:41 +05:30
Bhagya Amarasinghe 57d1c0ed99 fix: resolve feedback records PR check failures 2026-04-24 16:53:57 +05:30
Tiago 817b299436 chore: rename gcp ai provider to google (#7815) 2026-04-24 10:10:58 +00:00
Tiago Farto c140dae872 Merge branch 'epic/v5' into chore/rename_google 2026-04-24 09:51:39 +00:00
Bhagya Amarasinghe 6036a8c767 fix: harden FeedbackRecords Envoy auth routing 2026-04-24 13:51:54 +05:30
Dhruwang Jariwala bf592937f4 feat: AI-powered survey translation (#7793) 2026-04-24 12:55:36 +05:30
Bhagya Amarasinghe 1cfadd968a feat: add FeedbackRecords Envoy gateway 2026-04-24 02:17:54 +05:30
Tiago Farto 21ed383a46 chore: address PR concerns 2026-04-23 13:45:09 +00:00
Bhagya Amarasinghe 7ed7101ac1 feat: adds feedback record directory auth to api keys (#7804) 2026-04-23 18:04:17 +05:30
Tiago Farto 7aa12a4f0c chore: rename google ai things 2026-04-23 12:27:39 +00:00
pandeymangg 2e926936fb addressed feedback 2026-04-23 17:46:09 +05:30
Dhruwang 8edef8aede refactor: replace repeated union type with TDimension alias in TBaseStyling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:22:13 +05:30
Dhruwang 54fb202285 fix: add jsonb_typeof guard to legacy styling migration
Ensures the UPDATE only processes JSONB objects, preventing errors
on unexpected scalar or array values in the styling column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:16:50 +05:30
Dhruwang c720a462a7 fix: rename inputColor to inputTextColor in survey-ui storybook files
Aligns storybook story helpers and element stories with the legacy
field removal — inputColor → inputTextColor, mapping to
--fb-input-text-color CSS variable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:12:53 +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
Tiago Farto 7dde3edd8d chore: fix tests 2026-04-20 12:30:03 +00:00
Dhruwang 730ab6a609 fix: use valid hex colors in styles unit tests
Replace invalid fake hex values (e.g. "#btn-bg", "#headline-color") with
valid hex colors so isLight() and mixColor() don't throw. Add missing
inputTextColor to the survey styling test so --fb-placeholder-color is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 17:41:19 +05:30
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
Dhruwang 4e75a57692 chore: remove legacy styling fields (questionColor, inputColor) for v5
Add a SQL migration that copies legacy coarse-grained styling fields
to granular equivalents (e.g. questionColor → elementHeadlineColor,
inputColor → inputBgColor) and strips the legacy keys from the JSONB.

Remove the runtime deriveNewFieldsFromLegacy() shim, all fallback
chains in CSS variable generation, and update types, schemas, tests,
and OpenAPI spec to reflect the new canonical field names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 17:00:20 +05:30
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 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
Tiago Farto 7aed1b84de chore: translations, fixes 2026-04-17 11:59:17 +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
Tiago 31d2ea7444 chore: Move Response Pipeline to BullMQ (#7695) 2026-04-15 10:12:41 +03:00
pandeymangg 3da7129413 fixes tests 2026-04-14 17:09:13 +05:30
pandeymangg 75fbb23190 chore: merge with main 2026-04-14 17:01:17 +05:30
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
Dhruwang Jariwala 60f6ca9463 chore: deprecate environments (#7693)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-10 09:13:47 +04: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
pandeymangg aa27d242bb chore: merge with main 2026-04-09 15:26:30 +05:30
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
Dhruwang Jariwala a771ae189a refactor: rename Project to Workspace across entire codebase (#7620) 2026-03-31 17:01:17 +05:30
Anshuman Pandey 029e069af6 feat: feedback record directories (#7592) 2026-03-27 04:18:20 -07:00
Matti Nannt 81272b96e1 feat: port hub xm-suite config to epic/v5 (#7578) 2026-03-25 11:04:42 +00:00
572 changed files with 9828 additions and 29814 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
+3 -21
View File
@@ -63,18 +63,10 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/hub?sslmode=disable
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
# Hub image tag used by docker-compose.dev.yml (hub + hub-migrate). Leave unset to use the
# pinned default in the compose file; override here when testing a specific Hub release.
# HUB_IMAGE_TAG=0.3.0
# Hub embeddings are optional. Set a provider and model to enable semantic search embeddings in
# the Hub API and hub-worker. For provider-specific settings, see:
# https://hub.formbricks.com/reference/environment-variables/#embeddings
# Example with Google AI Studio:
# EMBEDDING_PROVIDER=google
# EMBEDDING_MODEL=gemini-embedding-001
# EMBEDDING_PROVIDER_API_KEY=
# HUB_IMAGE_TAG=0.2.0
###########################
# CUBE ANALYTICS (XM V5) #
@@ -120,7 +112,7 @@ SMTP_PASSWORD=smtpPassword
# S3 STORAGE #
##############
# S3 Storage is required for the file upload in serverless environments
# S3 Storage is required for the file upload in serverless environments like Vercel
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=
@@ -156,13 +148,6 @@ PASSWORD_RESET_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1
###########################################
# Account deletion reauthentication #
###########################################
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
##########
# Other #
@@ -189,9 +174,6 @@ GITHUB_SECRET=
# Configure Google Login
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Google only returns the auth_time proof after Auth Platform Security Bundle "Session age claims" is enabled.
# Keep this unset until that setting is active for the OAuth app.
# GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
# Configure Azure Active Directory Login
AZUREAD_CLIENT_ID=
-78
View File
@@ -1,78 +0,0 @@
name: Accessibility issue
description: "Report an accessibility barrier in Formbricks (WCAG, screen reader, keyboard, contrast, etc.)"
type: bug
labels: ["accessibility", "bug"]
body:
- type: markdown
attributes:
value: |
Thanks for helping make Formbricks accessible to everyone. Please fill in as much as you can — see [ACCESSIBILITY.md](https://github.com/formbricks/formbricks/blob/main/ACCESSIBILITY.md) for context.
- type: textarea
id: summary
attributes:
label: Summary
description: What part of Formbricks is affected and what's wrong?
placeholder: "e.g. The language switcher in survey runtime can't be reached with Tab."
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1. Open a survey with multiple languages
2. Press Tab repeatedly
3. Focus never lands on the language switcher
validations:
required: true
- type: input
id: wcag
attributes:
label: Related WCAG criterion (if known)
placeholder: "e.g. 2.1.1 Keyboard"
- type: dropdown
id: severity
attributes:
label: Severity
options:
- "Critical — blocks a user from completing a core task"
- "High — significant barrier with no easy workaround"
- "Medium — barrier with a workaround"
- "Low — minor friction"
validations:
required: true
- type: input
id: at
attributes:
label: Assistive technology
placeholder: "e.g. NVDA 2026.1, VoiceOver on macOS 15, keyboard only"
- type: input
id: browser
attributes:
label: Browser and OS
placeholder: "e.g. Firefox 138 on Windows 11"
- type: dropdown
id: environment
attributes:
label: Your Environment
options:
- Formbricks Cloud (app.formbricks.com)
- Self-hosted Formbricks
validations:
required: true
- type: textarea
id: other
attributes:
label: Other information (screenshots, recordings, axe output)
+4 -4
View File
@@ -20,12 +20,12 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- name: Cache Build
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
uses: actions/cache@v3
id: cache-build
env:
cache-name: prod-build
@@ -43,7 +43,7 @@ runs:
shell: bash
- name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v3
with:
node-version: 20.x
if: steps.cache-build.outputs.cache-hit != 'true'
@@ -53,7 +53,7 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
@@ -147,10 +147,6 @@ jobs:
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-e REDIS_URL="redis://host.docker.internal:6379" \
-e HUB_API_URL="http://localhost:4000" \
-e HUB_API_KEY="build-time-placeholder" \
-e CUBEJS_API_URL="http://localhost:4000" \
-e CUBEJS_API_SECRET="build-time-placeholder" \
-d "formbricks-test:$GITHUB_SHA"
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
+48 -37
View File
@@ -57,7 +57,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 22.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with:
node-version: 22.x
@@ -65,7 +65,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
shell: bash
- name: Create .env
@@ -81,48 +81,65 @@ jobs:
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=devrustfs-service" >> .env
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
- name: Start RustFS Server
- name: Install MinIO client (mc)
run: |
set -euo pipefail
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
MC_BIN="mc.${MC_VERSION}"
MC_SUM="${MC_BIN}.sha256sum"
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
sha256sum -c "${MC_SUM}"
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
run: |
set -euo pipefail
# Start RustFS server in background
# Start MinIO server in background
docker run -d \
--name rustfs-server \
--name minio-server \
-p 9000:9000 \
-p 9001:9001 \
-e RUSTFS_ACCESS_KEY=devrustfs \
-e RUSTFS_SECRET_KEY=devrustfs123 \
-e RUSTFS_ADDRESS=:9000 \
-e RUSTFS_CONSOLE_ENABLE=true \
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
rustfs/rustfs:1.0.0-alpha.93 \
/data
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
echo "RustFS server started"
echo "MinIO server started"
- name: Bootstrap RustFS bucket and browser upload CORS
- name: Wait for MinIO and create S3 bucket
run: |
set -euo pipefail
docker run --rm \
--network host \
--entrypoint /bin/sh \
-e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \
-e RUSTFS_ADMIN_USER=devrustfs \
-e RUSTFS_ADMIN_PASSWORD=devrustfs123 \
-e RUSTFS_SERVICE_USER=devrustfs-service \
-e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \
-e RUSTFS_BUCKET_NAME=formbricks-e2e \
-e RUSTFS_POLICY_NAME=formbricks-e2e-policy \
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
-v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
/tmp/rustfs-init.sh
echo "Waiting for MinIO to be ready..."
ready=0
for i in {1..60}; do
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
echo "MinIO is up after ${i} seconds"
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "::error::MinIO did not become ready within 60 seconds"
exit 1
fi
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
- name: Build App
run: |
@@ -221,14 +238,8 @@ jobs:
if: failure()
with:
name: app-logs
if-no-files-found: ignore
path: app.log
- name: Output App Logs
if: failure()
run: |
if [ -f app.log ]; then
cat app.log
else
echo "app.log not found because the Run App step did not execute or failed before log creation."
fi
run: cat app.log
-28
View File
@@ -155,31 +155,3 @@ jobs:
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
linear-release-complete:
name: Mark Linear release as complete
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- docker-build-community
- docker-build-cloud
- helm-chart-release
- move-stable-tag
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Complete Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
version: ${{ github.event.release.tag_name }}
-30
View File
@@ -1,30 +0,0 @@
name: Linear Release Sync
on:
push:
branches:
- main
permissions:
contents: read
jobs:
linear-release:
name: Sync release to Linear
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Sync Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 20.x
@@ -29,7 +29,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create .env
run: pnpm dev:setup
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 22.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
@@ -33,7 +33,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create .env
run: pnpm dev:setup
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with:
node-version: 20.x
@@ -30,7 +30,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create .env
run: pnpm dev:setup
+2 -3
View File
@@ -2,7 +2,6 @@ name: Translation Validation
permissions:
contents: read
pull-requests: read
on:
pull_request:
@@ -40,7 +39,7 @@ jobs:
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
@@ -50,7 +49,7 @@ jobs:
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys
if: steps.changes.outputs.translations == 'true'
+1
View File
@@ -0,0 +1 @@
apps/web/.env
-48
View File
@@ -1,48 +0,0 @@
# Accessibility
Formbricks is committed to making our platform usable by everyone, including people who rely on assistive technologies.
## Standards
We aim to conform to:
- **[WCAG 2.1 Level AA](https://www.w3.org/TR/WCAG21/)** — the web content baseline.
- **[EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)** — the European harmonised standard referenced by the **European Accessibility Act (EAA)**, applicable to us as a Germany-based company.
- **Section 508** — for users in US public-sector contexts.
## Priorities
1. **End-user surveys** (`packages/surveys`) — everything respondents see and interact with. This is our highest priority because survey takers don't choose Formbricks; the organisations running surveys choose for them.
2. **Admin app** (`apps/web`) — survey creation, response analysis, and team management used by Formbricks customers.
In both areas we focus on:
- Keyboard navigation with a clearly visible focus indicator
- Screen reader support through semantic HTML and correctly scoped ARIA
- Sufficient color and contrast
- Programmatically associated labels and announced status messages
## Supported Environments
- Latest two versions of Chrome, Firefox, Safari, and Edge
- VoiceOver (macOS/iOS), NVDA (Windows), and TalkBack (Android)
## Contributing
When contributing UI changes:
- Prefer semantic HTML over ARIA.
- Tab through your change end-to-end and confirm focus is visible at every stop.
- Label every control. Don't convey meaning by color alone.
- Run [axe DevTools](https://www.deque.com/axe/devtools/) or Lighthouse on the page you changed.
## Reporting Accessibility Issues
If you encounter an accessibility barrier, please [open an issue](https://github.com/formbricks/formbricks/issues/new?labels=accessibility&template=accessibility.yml) using the accessibility template. For blocking issues in a procurement or compliance context, email **[hola@formbricks.com](mailto:hola@formbricks.com)**.
## Resources
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
- [EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)
- [European Accessibility Act overview](https://ec.europa.eu/social/main.jsp?catId=1202)
- [MDN Accessibility Reference](https://developer.mozilla.org/en-US/docs/Web/Accessibility)
+12 -12
View File
@@ -11,19 +11,19 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"devDependencies": {
"@chromatic-com/storybook": "5.0.2",
"@storybook/addon-a11y": "10.3.6",
"@storybook/addon-docs": "10.3.6",
"@storybook/addon-links": "10.3.6",
"@storybook/addon-onboarding": "10.3.6",
"@storybook/react-vite": "10.3.6",
"@tailwindcss/vite": "4.2.4",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.17",
"@storybook/addon-links": "10.2.17",
"@storybook/addon-onboarding": "10.2.17",
"@storybook/react-vite": "10.2.17",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.57.0",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.3.6",
"storybook": "10.3.6",
"vite": "7.3.3"
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.2",
"@storybook/addon-docs": "10.2.17"
}
}
@@ -1,4 +1,4 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
@@ -1,6 +1,6 @@
"use server";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
@@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache(
name: team.name,
}));
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -2,7 +2,6 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
@@ -42,16 +41,6 @@ const Page = async (props: ChannelPageProps) => {
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "surveys",
},
{ organizationId: params.organizationId }
);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -18,7 +18,6 @@ import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/acti
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { toJsWorkspaceStateSurvey } from "@/lib/survey/client-utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
@@ -238,7 +237,7 @@ export const WorkspaceSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={toJsWorkspaceStateSurvey(previewSurvey(workspaceName || t("common.my_product"), t))}
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
@@ -11,7 +11,6 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -56,18 +55,6 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
const publicDomain = getPublicDomain();
if (searchParams.mode === "cx") {
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "cx",
},
{ organizationId: params.organizationId }
);
}
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -10,7 +10,6 @@ import {
import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -81,19 +80,6 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
notificationSettings: updatedNotificationSettings,
});
groupIdentifyPostHog("workspace", workspace.id, { name: workspace.name });
capturePostHogEvent(
user.id,
"workspace_created",
{
organization_id: organizationId,
workspace_id: workspace.id,
name: workspace.name,
},
{ organizationId, workspaceId: workspace.id }
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.workspaceId = workspace.id;
ctx.auditLoggingCtx.newObject = workspace;
@@ -43,7 +43,6 @@ import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -152,17 +151,7 @@ export const MainNavigation = ({
},
{
id: "unify-feedback",
name: (
<span className="inline-flex items-center gap-2">
<span>{t("workspace.unify.unify_feedback")}</span>
<Badge
text="Beta"
type="gray"
size="tiny"
className="normal-case text-[10px] font-semibold tracking-normal"
/>
</span>
),
name: t("workspace.unify.unify_feedback"),
items: [
{
name: t("workspace.unify.feedback_records"),
@@ -2,8 +2,6 @@ import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { WorkspaceLayout as WorkspaceLayoutComponent } from "@/app/(app)/workspaces/[workspaceId]/components/WorkspaceLayout";
import { WorkspaceContextWrapper } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
import { POSTHOG_KEY } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getWorkspaceLayoutData } from "@/modules/workspaces/lib/utils";
import WorkspaceStorageHandler from "./components/WorkspaceStorageHandler";
@@ -25,14 +23,6 @@ const WorkspaceLayout = async (props: {
return (
<>
<WorkspaceStorageHandler workspaceId={params.workspaceId} />
{POSTHOG_KEY && (
<PostHogGroupIdentify
organizationId={layoutData.organization.id}
organizationName={layoutData.organization.name}
workspaceId={layoutData.workspace.id}
workspaceName={layoutData.workspace.name}
/>
)}
<WorkspaceContextWrapper workspace={layoutData.workspace} organization={layoutData.organization}>
<WorkspaceLayoutComponent layoutData={layoutData}>{children}</WorkspaceLayoutComponent>
</WorkspaceContextWrapper>
@@ -6,9 +6,11 @@ import {
TUserUpdateInput,
ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user";
import { getIsEmailUnique } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/lib/user";
import {
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { verifyUserPassword } from "@/lib/user/password";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -1,68 +1,30 @@
"use client";
import type { Session } from "next-auth";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import {
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
} from "@/modules/account/constants";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface DeleteAccountProps {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
accountDeletionError?: string | string[];
isMultiOrgEnabled: boolean;
requiresPasswordConfirmation: boolean;
}
export const DeleteAccount = ({
session,
IS_FORMBRICKS_CLOUD,
user,
organizationsWithSingleOwner,
accountDeletionError,
isMultiOrgEnabled,
requiresPasswordConfirmation,
}: Readonly<DeleteAccountProps>) => {
}: {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
isMultiOrgEnabled: boolean;
}) => {
const [isModalOpen, setModalOpen] = useState(false);
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
const { t } = useTranslation();
const accountDeletionErrorCode = Array.isArray(accountDeletionError)
? accountDeletionError[0]
: accountDeletionError;
const hasShownAccountDeletionError = useRef(false);
useEffect(() => {
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
return;
}
hasShownAccountDeletionError.current = true;
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
toast.error(t("workspace.settings.profile.google_sso_account_deletion_requires_setup"), {
id: "account-deletion-sso-reauth-error",
});
} else {
toast.error(t("workspace.settings.profile.sso_reauthentication_failed"), {
id: "account-deletion-sso-reauth-error",
});
}
const url = new URL(globalThis.location.href);
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
globalThis.history.replaceState(null, "", url.toString());
}, [accountDeletionErrorCode, t]);
if (!session) {
return null;
}
@@ -70,7 +32,6 @@ export const DeleteAccount = ({
return (
<div>
<DeleteAccountModal
requiresPasswordConfirmation={requiresPasswordConfirmation}
open={isModalOpen}
setOpen={setModalOpen}
user={user}
@@ -1,6 +1,12 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getIsEmailUnique } from "./user";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { getIsEmailUnique, verifyUserPassword } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -11,12 +17,92 @@ vi.mock("@formbricks/database", () => ({
}));
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
describe("User Library Tests", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("verifyUserPassword", () => {
const userId = "test-user-id";
const password = "test-password";
test("should return true for correct password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(true);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should return false for incorrect password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(false);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should throw ResourceNotFoundError if user not found", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if identityProvider is not email", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "google", // Not 'email'
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if password is not set for email provider", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: null, // Password not set
identityProvider: "email",
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
});
describe("getIsEmailUnique", () => {
const email = "test@example.com";
@@ -1,5 +1,42 @@
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
export const getUserById = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserById(userId);
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
const isCorrectPassword = await verifyPassword(password, user.password);
if (!isCorrectPassword) {
return false;
}
return true;
};
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
const user = await prisma.user.findUnique({
@@ -7,7 +7,6 @@ import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABL
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
@@ -15,14 +14,10 @@ import { PageHeader } from "@/modules/ui/components/page-header";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: {
params: Promise<{ workspaceId: string }>;
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
}) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const params = await props.params;
const searchParams = await props.searchParams;
const t = await getTranslate();
const { session } = await getWorkspaceAuth(params.workspaceId);
@@ -35,7 +30,6 @@ const Page = async (props: {
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
return (
<PageContentWrapper>
@@ -91,8 +85,6 @@ const Page = async (props: {
user={user}
organizationsWithSingleOwner={organizationsWithSingleOwner}
isMultiOrgEnabled={isMultiOrgEnabled}
accountDeletionError={searchParams.accountDeletionError}
requiresPasswordConfirmation={requiresPasswordConfirmation}
/>
</SettingsCard>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
@@ -5,7 +5,7 @@ import { RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
@@ -151,17 +151,12 @@ export const EnterpriseLicenseStatus = ({
</Alert>
)}
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
<Trans
i18nKey="workspace.settings.enterprise.questions_please_reach_out_to_email"
components={{
contactLink: (
<a
className="font-medium text-slate-700 underline hover:text-slate-900"
href="mailto:hola@formbricks.com"
/>
),
}}
/>
{t("workspace.settings.enterprise.questions_please_reach_out_to")}{" "}
<a
className="font-medium text-slate-700 underline hover:text-slate-900"
href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
</p>
</div>
</SettingsCard>
@@ -3,7 +3,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { Button } from "@/modules/ui/components/button";
@@ -163,7 +163,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
</p>
<Button asChild>
<Link
href={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
target="_blank"
rel="noopener noreferrer nofollow"
referrerPolicy="no-referrer">
@@ -95,7 +95,9 @@ export const AISettingsToggle = ({
},
{
text: t("common.learn_more"),
href: "https://formbricks.com/docs/platform/features/ai-features",
href: isFormbricksCloud
? `${workspaceBasePath}/settings/organization/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
];
@@ -1,10 +1,5 @@
import { isInstanceAIConfigured } from "@/lib/ai/service";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
FB_LOGO_URL,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
} from "@/lib/constants";
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 {
@@ -86,7 +81,6 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
fbLogoUrl={FB_LOGO_URL}
user={user}
isStorageConfigured={IS_STORAGE_CONFIGURED}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
{isMultiOrgEnabled && (
<SettingsCard
@@ -46,16 +46,10 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
capturePostHogEvent(
ctx.user.id,
"integration_connected",
{
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
workspace_id: parsedInput.workspaceId,
},
{ organizationId, workspaceId: parsedInput.workspaceId }
);
capturePostHogEvent(ctx.user.id, "integration_connected", {
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
});
return result;
})
@@ -18,7 +18,6 @@ interface AirtableWrapperProps {
isEnabled: boolean;
webAppUrl: string;
locale: TUserLocale;
showReconnectButton?: boolean;
}
export const AirtableWrapper = ({
@@ -29,7 +28,6 @@ export const AirtableWrapper = ({
isEnabled,
webAppUrl,
locale,
showReconnectButton = false,
}: AirtableWrapperProps) => {
const [isConnected, setIsConnected] = useState(
airtableIntegration ? airtableIntegration.config?.key : false
@@ -51,8 +49,6 @@ export const AirtableWrapper = ({
setIsConnected={setIsConnected}
surveys={surveys}
locale={locale}
showReconnectButton={showReconnectButton}
handleAirtableAuthorization={handleAirtableAuthorization}
/>
) : (
<ConnectIntegration
@@ -1,6 +1,6 @@
"use client";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,11 +12,9 @@ import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/se
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
@@ -26,20 +24,10 @@ interface ManageIntegrationProps {
surveys: TSurvey[];
airtableArray: TIntegrationItem[];
locale: TUserLocale;
showReconnectButton: boolean;
handleAirtableAuthorization: () => Promise<void>;
}
export const ManageIntegration = ({
airtableIntegration,
workspaceId,
setIsConnected,
surveys,
airtableArray,
showReconnectButton,
handleAirtableAuthorization,
locale,
}: ManageIntegrationProps) => {
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, workspaceId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslation();
const tableHeaders = [
@@ -85,34 +73,15 @@ export const ManageIntegration = ({
: { isEditMode: false as const };
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>{t("workspace.integrations.reconnect_button_description")}</AlertDescription>
<AlertButton onClick={handleAirtableAuthorization}>
{t("workspace.integrations.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<div className="flex w-full justify-end gap-x-6">
<div className="flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
<span className="cursor-pointer text-slate-500">
{t("workspace.integrations.connected_with_email", {
email: airtableIntegration.config.email,
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleAirtableAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("workspace.integrations.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("workspace.integrations.reconnect_button_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setDefaultValues(null);
@@ -153,7 +122,9 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.tableName}</div>
<div className="col-span-2 text-center">{data.elements}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)}
</div>
</button>
))}
</div>
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation";
import { logger } from "@formbricks/logger";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AirtableWrapper";
@@ -32,14 +31,8 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
);
let airtableArray: TIntegrationItem[] = [];
let isTokenValid = true;
if (airtableIntegration?.config.key) {
try {
airtableArray = await getAirtableTables(workspace.id);
} catch (error) {
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
isTokenValid = false;
}
airtableArray = await getAirtableTables(workspace.id);
}
if (isReadOnly) {
return redirect("./");
@@ -58,7 +51,6 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
showReconnectButton={!isTokenValid}
/>
</div>
</PageContentWrapper>
@@ -1,6 +1,6 @@
"use client";
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import React, { createContext, useCallback, useContext, useState } from "react";
import {
ElementOption,
ElementOptions,
@@ -30,7 +30,7 @@ interface SelectedFilterOptions {
export interface DateRange {
from: Date | undefined;
to?: Date;
to?: Date | undefined;
}
interface FilterDateContextProps {
@@ -41,8 +41,6 @@ interface FilterDateContextProps {
dateRange: DateRange;
setDateRange: React.Dispatch<React.SetStateAction<DateRange>>;
resetState: () => void;
refreshAnalysisData: () => Promise<void>;
registerAnalysisRefreshHandler: (handler: () => Promise<void>) => () => void;
}
const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined);
@@ -63,7 +61,6 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
from: undefined,
to: getTodayDate(),
});
const refreshHandlerRef = useRef<(() => Promise<void>) | null>(null);
const resetState = useCallback(() => {
setDateRange({
@@ -76,43 +73,20 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
});
}, []);
const refreshAnalysisData = useCallback(async () => {
await refreshHandlerRef.current?.();
}, []);
const registerAnalysisRefreshHandler = useCallback((handler: () => Promise<void>) => {
refreshHandlerRef.current = handler;
return () => {
if (refreshHandlerRef.current === handler) {
refreshHandlerRef.current = null;
}
};
}, []);
const contextValue = useMemo(
() => ({
setSelectedFilter,
selectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
refreshAnalysisData,
registerAnalysisRefreshHandler,
}),
[
dateRange,
refreshAnalysisData,
registerAnalysisRefreshHandler,
resetState,
selectedFilter,
selectedOptions,
]
return (
<ResponseFilterContext.Provider
value={{
setSelectedFilter,
selectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
}}>
{children}
</ResponseFilterContext.Provider>
);
return <ResponseFilterContext.Provider value={contextValue}>{children}</ResponseFilterContext.Provider>;
};
const useResponseFilter = () => {
@@ -2,8 +2,6 @@
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -14,7 +12,6 @@ import { useResponseFilter } from "@/app/(app)/workspaces/[workspaceId]/surveys/
import { ResponseDataView } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
interface ResponsePageProps {
@@ -46,8 +43,8 @@ export const ResponsePage = ({
const [page, setPage] = useState<number | null>(null);
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const { t } = useTranslation();
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
@@ -86,34 +83,6 @@ export const ResponsePage = ({
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
};
const refetchResponses = useCallback(async () => {
setIsFetchingFirstPage(true);
try {
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
if (getResponsesActionResponse?.serverError) {
toast.error(getFormattedErrorMessage(getResponsesActionResponse) ?? t("common.something_went_wrong"));
}
const freshResponses = getResponsesActionResponse?.data ?? [];
setResponses(freshResponses);
setPage(1);
setHasMore(freshResponses.length >= responsesPerPage);
} finally {
setIsFetchingFirstPage(false);
}
}, [filters, responsesPerPage, surveyId]);
useEffect(() => {
return registerAnalysisRefreshHandler(refetchResponses);
}, [refetchResponses, registerAnalysisRefreshHandler]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
}, [survey]);
@@ -162,8 +131,6 @@ export const ResponsePage = ({
}
};
fetchFilteredResponses();
// page is intentionally omitted to avoid refetching after the initial page setup.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
return (
@@ -1,4 +1,5 @@
import { TFunction } from "i18next";
import { capitalize } from "lodash";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -8,7 +9,6 @@ import {
SmartphoneIcon,
} from "lucide-react";
import { TResponseMeta } from "@formbricks/types/responses";
import { capitalize } from "@/lib/utils/object";
export const getAddressFieldLabel = (field: string, t: TFunction) => {
switch (field) {
@@ -2,12 +2,7 @@ import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/er
import { SurveyAnalysisNavigation } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
RESPONSES_PER_PAGE,
} from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -69,6 +64,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string; surveyId: st
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
@@ -77,7 +73,6 @@ const Page = async (props: { params: Promise<{ workspaceId: string; surveyId: st
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isStorageConfigured={IS_STORAGE_CONFIGURED}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
}>
<SurveyAnalysisNavigation survey={survey} activeId="responses" />
@@ -4,7 +4,6 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -139,6 +138,7 @@ export const getEmailHtmlAction = authenticatedActionClient
const ZGeneratePersonalLinksAction = z.object({
surveyId: ZId,
segmentId: ZId,
workspaceId: ZId,
expirationDays: z.number().optional(),
});
@@ -146,7 +146,6 @@ export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this workspace");
@@ -154,7 +153,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
@@ -162,7 +161,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
},
{
type: "workspaceTeam",
workspaceId,
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
@@ -179,18 +178,6 @@ export const generatePersonalLinksAction = authenticatedActionClient
throw new UnknownError("No contacts found for the selected segment");
}
capturePostHogEvent(
ctx.user.id,
"personal_link_created",
{
organization_id: organizationId,
workspace_id: workspaceId,
survey_id: parsedInput.surveyId,
link_count: contactsResult.length,
},
{ organizationId, workspaceId }
);
// Prepare CSV data with the specified headers and order
const csvHeaders = [
"Formbricks Contact ID",
@@ -4,12 +4,15 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
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 { useSurvey } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/context/survey-context";
import { Confetti } from "@/modules/ui/components/confetti";
export const SuccessMessage = () => {
const { survey } = useSurvey();
interface SummaryMetadataProps {
survey: TSurvey;
}
export const SuccessMessage = ({ survey }: SummaryMetadataProps) => {
const { t } = useTranslation();
const { workspace } = useWorkspaceContext();
const searchParams = useSearchParams();
@@ -68,7 +68,7 @@ export const SummaryPage = ({
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
@@ -108,7 +108,7 @@ export const SummaryPage = ({
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays]);
}, [fetchDisplays, t]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
@@ -128,39 +128,13 @@ export const SummaryPage = ({
}
}, [tab, loadInitialDisplays]);
const fetchSummary = useCallback(async () => {
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
const updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
if (updatedSurveySummary?.serverError) {
throw new Error(getFormattedErrorMessage(updatedSurveySummary));
}
setSurveySummary(updatedSurveySummary?.data ?? defaultSurveySummary);
}, [dateRange, selectedFilter, survey, surveyId]);
const refreshSummary = useCallback(async () => {
setIsLoading(true);
try {
await Promise.all([fetchSummary(), tab === "impressions" ? loadInitialDisplays() : Promise.resolve()]);
} finally {
setIsLoading(false);
}
}, [fetchSummary, loadInitialDisplays, tab]);
useEffect(() => {
return registerAnalysisRefreshHandler(refreshSummary);
}, [refreshSummary, registerAnalysisRefreshHandler]);
// Only fetch data when filters change or when there's no initial data
useEffect(() => {
// If we have initial data and no filters are applied, don't fetch
const hasNoFilters =
(!selectedFilter || Object.keys(selectedFilter).length === 0 || selectedFilter.filter?.length === 0) &&
(!selectedFilter ||
Object.keys(selectedFilter).length === 0 ||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
(!dateRange || (!dateRange.from && !dateRange.to));
if (initialSurveySummary && hasNoFilters) {
@@ -168,11 +142,21 @@ export const SummaryPage = ({
return;
}
const fetchFilteredSummary = async () => {
const fetchSummary = async () => {
setIsLoading(true);
try {
await fetchSummary();
// Recalculate filters inside the effect to ensure we have the latest values
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
let updatedSurveySummary;
updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
setSurveySummary(surveySummary);
} catch (error) {
console.error(error);
} finally {
@@ -180,8 +164,8 @@ export const SummaryPage = ({
}
};
fetchFilteredSummary();
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
fetchSummary();
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
@@ -1,18 +1,17 @@
"use client";
import { BellRing, Eye, ListRestart, RefreshCcwIcon, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { useResponseFilter } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { SuccessMessage } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { ShareSurveyModal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { SurveyStatusDropdown } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { useSurvey } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/context/survey-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
@@ -23,6 +22,7 @@ import { IconBar } from "@/modules/ui/components/iconbar";
import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
isReadOnly: boolean;
user: TUser;
publicDomain: string;
@@ -31,7 +31,6 @@ interface SurveyAnalysisCTAProps {
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
isStorageConfigured: boolean;
enterpriseLicenseRequestFormUrl: string;
}
interface ModalState {
@@ -40,6 +39,7 @@ interface ModalState {
}
export const SurveyAnalysisCTA = ({
survey,
isReadOnly,
user,
publicDomain,
@@ -48,7 +48,6 @@ export const SurveyAnalysisCTA = ({
isContactsEnabled,
isFormbricksCloud,
isStorageConfigured,
enterpriseLicenseRequestFormUrl,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -61,12 +60,9 @@ export const SurveyAnalysisCTA = ({
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const { workspace } = useWorkspaceContext();
const { survey } = useSurvey();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const { refreshAnalysisData } = useResponseFilter();
const appSetupCompleted = survey.type === "app" && workspace.appSetupCompleted;
@@ -78,7 +74,7 @@ export const SurveyAnalysisCTA = ({
}, [searchParams]);
const handleShareModalToggle = (open: boolean) => {
const params = new URLSearchParams(globalThis.location.search);
const params = new URLSearchParams(window.location.search);
const currentShareParam = params.get("share") === "true";
if (open && !currentShareParam) {
@@ -113,12 +109,9 @@ export const SurveyAnalysisCTA = ({
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
if (survey.singleUse?.enabled) {
const singleUseLinkParams = await refreshSingleUseId();
if (singleUseLinkParams) {
surveyUrl.searchParams.set("suId", singleUseLinkParams.suId);
if (singleUseLinkParams.suToken) {
surveyUrl.searchParams.set("suToken", singleUseLinkParams.suToken);
}
const newId = await refreshSingleUseId();
if (newId) {
surveyUrl.searchParams.set("suId", newId);
}
}
@@ -151,25 +144,6 @@ export const SurveyAnalysisCTA = ({
};
const iconActions = [
{
icon: RefreshCcwIcon,
tooltip: t("common.refresh"),
onClick: async () => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
await refreshAnalysisData();
toast.success(t("common.data_refreshed_successfully"));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
} finally {
setIsRefreshing(false);
}
},
disabled: isRefreshing,
isVisible: true,
},
{
icon: BellRing,
tooltip: t("workspace.surveys.summary.configure_alerts"),
@@ -206,7 +180,7 @@ export const SurveyAnalysisCTA = ({
return (
<div className="hidden justify-end gap-x-1.5 sm:flex">
{!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
<SurveyStatusDropdown />
<SurveyStatusDropdown survey={survey} />
)}
<IconBar actions={iconActions} />
@@ -236,10 +210,9 @@ export const SurveyAnalysisCTA = ({
isReadOnly={isReadOnly}
isStorageConfigured={isStorageConfigured}
workspaceCustomScripts={workspace.customHeadScripts}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
/>
)}
<SuccessMessage />
<SuccessMessage survey={survey} />
{responseCount > 0 && (
<EditPublicSurveyAlertDialog
@@ -54,7 +54,6 @@ interface ShareSurveyModalProps {
isReadOnly: boolean;
isStorageConfigured: boolean;
workspaceCustomScripts?: string | null;
enterpriseLicenseRequestFormUrl: string;
}
export const ShareSurveyModal = ({
@@ -70,7 +69,6 @@ export const ShareSurveyModal = ({
isReadOnly,
isStorageConfigured,
workspaceCustomScripts,
enterpriseLicenseRequestFormUrl,
}: ShareSurveyModalProps) => {
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
const [showView, setShowView] = useState<ModalView>(modalView);
@@ -104,11 +102,11 @@ export const ShareSurveyModal = ({
description: t("workspace.surveys.share.personal_links.description"),
componentType: PersonalLinksTab,
componentProps: {
workspaceId: survey.workspaceId,
surveyId: survey.id,
segments,
isContactsEnabled,
isFormbricksCloud,
enterpriseLicenseRequestFormUrl,
},
disabled: survey.singleUse?.enabled,
},
@@ -2,7 +2,7 @@
import { CirclePlayIcon, CopyIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -41,7 +41,6 @@ export const AnonymousLinksTab = ({
const [isSingleUseLink, setIsSingleUseLink] = useState(survey.singleUse?.enabled ?? false);
const [singleUseEncryption, setSingleUseEncryption] = useState(survey.singleUse?.isEncrypted ?? false);
const [numberOfLinks, setNumberOfLinks] = useState<number | string>(1);
const [customSingleUseId, setCustomSingleUseId] = useState("");
const [disableLinkModal, setDisableLinkModal] = useState<{
open: boolean;
@@ -49,6 +48,12 @@ export const AnonymousLinksTab = ({
pendingAction: () => Promise<void> | void;
} | null>(null);
const surveyUrlWithCustomSuid = useMemo(() => {
const url = new URL(surveyUrl);
url.searchParams.set("suId", "CUSTOM-ID");
return url.toString();
}, [surveyUrl]);
const resetState = () => {
const { singleUse } = survey;
const { enabled, isEncrypted } = singleUse ?? {};
@@ -176,13 +181,10 @@ export const AnonymousLinksTab = ({
});
if (!!response?.data?.length) {
const singleUseLinkParams = response.data;
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
const singleUseIds = response.data;
const surveyLinks = singleUseIds.map((singleUseId) => {
const url = new URL(surveyUrl);
url.searchParams.set("suId", suId);
if (suToken) {
url.searchParams.set("suToken", suToken);
}
url.searchParams.set("suId", singleUseId);
return url.toString();
});
@@ -210,40 +212,6 @@ export const AnonymousLinksTab = ({
}
};
const handleCopyCustomSingleUseLink = async () => {
const trimmedCustomSingleUseId = customSingleUseId.trim();
if (!trimmedCustomSingleUseId) {
toast.error(t("workspace.surveys.share.anonymous_links.custom_single_use_id_required"));
return;
}
try {
const response = await generateSingleUseIdsAction({
surveyId: survey.id,
isEncrypted: false,
count: 1,
singleUseId: trimmedCustomSingleUseId,
});
const singleUseLinkParams = response?.data?.[0];
if (!singleUseLinkParams) {
toast.error(t("workspace.surveys.share.anonymous_links.generate_links_error"));
return;
}
const url = new URL(surveyUrl);
url.searchParams.set("suId", singleUseLinkParams.suId);
if (singleUseLinkParams.suToken) {
url.searchParams.set("suToken", singleUseLinkParams.suToken);
}
await navigator.clipboard.writeText(url.toString());
toast.success(t("common.copied_to_clipboard"));
} catch {
toast.error(t("workspace.surveys.share.anonymous_links.generate_links_error"));
}
};
return (
<>
<div className="flex h-full flex-col justify-between space-y-4">
@@ -309,19 +277,16 @@ export const AnonymousLinksTab = ({
</Alert>
<div className="grid w-full grid-cols-6 items-center gap-2">
<Input
className="col-span-5 bg-white focus:border focus:border-slate-900"
value={customSingleUseId}
onChange={(event) => setCustomSingleUseId(event.target.value)}
placeholder={t(
"workspace.surveys.share.anonymous_links.custom_single_use_id_placeholder"
)}
/>
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
</div>
<Button
variant="secondary"
disabled={!customSingleUseId.trim()}
onClick={handleCopyCustomSingleUseLink}
onClick={() => {
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
toast.success(t("common.copied_to_clipboard"));
}}
className="col-span-1 gap-1 text-sm">
{t("common.copy")}
<CopyIcon />
@@ -2,7 +2,7 @@
import DOMPurify from "dompurify";
import { CopyIcon, SendIcon } from "lucide-react";
import { type SyntheticEvent, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { AuthenticationError } from "@formbricks/types/errors";
@@ -21,7 +21,6 @@ interface EmailTabProps {
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
const [activeTab, setActiveTab] = useState("preview");
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const [previewFrameHeight, setPreviewFrameHeight] = useState(560);
const { t } = useTranslation();
const emailHtml = useMemo(() => {
@@ -32,40 +31,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
const sanitizedEmailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return DOMPurify.sanitize(emailHtmlPreview, { ADD_ATTR: ["bgcolor", "target"] });
}, [emailHtmlPreview]);
const emailPreviewDocument = useMemo(() => {
if (!sanitizedEmailHtml) return "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="only light" />
<meta name="supported-color-schemes" content="light" />
<base target="_blank" />
<style>
:root {
color-scheme: only light;
supported-color-schemes: light;
}
html, body {
margin: 0;
padding: 0;
background: #ffffff;
color-scheme: only light;
}
</style>
</head>
<body>${sanitizedEmailHtml}</body>
</html>`;
}, [sanitizedEmailHtml]);
const tabs = [
{
id: "preview",
@@ -86,25 +51,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
getData();
}, [surveyId]);
useEffect(() => {
setPreviewFrameHeight(560);
}, [emailPreviewDocument]);
const handlePreviewFrameLoad = (event: SyntheticEvent<HTMLIFrameElement>) => {
const { contentDocument } = event.currentTarget;
if (!contentDocument) {
return;
}
const nextHeight = Math.max(
contentDocument.body.scrollHeight,
contentDocument.documentElement.scrollHeight,
560
);
setPreviewFrameHeight(nextHeight);
};
const sendPreviewEmail = async () => {
try {
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
@@ -127,9 +73,7 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
if (activeTab === "preview") {
return (
<div className="space-y-4 pb-4">
<div
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
data-testid="survey-email-preview-shell">
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-amber-500" />
@@ -143,17 +87,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
{t("workspace.surveys.share.send_email.email_subject_label")} :{" "}
{t("workspace.surveys.share.send_email.formbricks_email_survey_preview")}
</div>
<div data-testid="survey-email-preview-content">
{emailPreviewDocument ? (
<iframe
className="mt-2 w-full rounded-md border-0 bg-white"
data-testid="survey-email-preview-frame"
onLoad={handlePreviewFrameLoad}
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
srcDoc={emailPreviewDocument}
style={{ height: `${previewFrameHeight}px` }}
title={t("workspace.surveys.share.send_email.email_preview_tab")}
/>
<div className="p-2">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
) : (
<LoadingSpinner />
)}
@@ -30,11 +30,11 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { generatePersonalLinksAction } from "../../actions";
interface PersonalLinksTabProps {
workspaceId: string;
surveyId: string;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
enterpriseLicenseRequestFormUrl: string;
}
interface PersonalLinksFormData {
@@ -70,11 +70,11 @@ const RestrictedDatePicker = ({
};
export const PersonalLinksTab = ({
workspaceId,
segments,
surveyId,
isContactsEnabled,
isFormbricksCloud,
enterpriseLicenseRequestFormUrl,
}: PersonalLinksTabProps) => {
const { t } = useTranslation();
const { workspace } = useWorkspace();
@@ -117,6 +117,7 @@ export const PersonalLinksTab = ({
const result = await generatePersonalLinksAction({
surveyId: surveyId,
segmentId: selectedSegment,
workspaceId: workspaceId,
expirationDays: expiryDate
? Math.max(1, Math.floor((expiryDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)))
: undefined,
@@ -170,7 +171,7 @@ export const PersonalLinksTab = ({
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/workspaces/${workspace?.id}/settings/organization/billing`
: enterpriseLicenseRequestFormUrl,
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
@@ -16,19 +16,13 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
const { t } = useTranslation();
const separator = surveyUrl.includes("?") ? "&" : "?";
const iframeSrc = embedModeEnabled ? `${surveyUrl}${separator}embed=true` : surveyUrl;
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
<iframe
src="${iframeSrc}"
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
<iframe
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
</iframe>
</div>`;
const previewSrc = `${iframeSrc}${iframeSrc.includes("?") ? "&" : "?"}preview=true`;
return (
<>
<CodeBlock language="html" noMargin>
@@ -54,15 +48,6 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
{t("common.copy_code")}
<CopyIcon />
</Button>
<p className="text-base font-medium text-slate-800">{t("common.preview")}</p>
<div className="relative h-[500px] w-full overflow-hidden rounded-lg border border-slate-300">
<iframe
title={t("common.preview")}
src={previewSrc}
className="absolute inset-0 h-full w-full border-0"
/>
</div>
</>
);
};
@@ -1,59 +0,0 @@
import { describe, expect, test } from "vitest";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
describe("extractEmailBodyFragment", () => {
test("returns the body contents for rendered email documents", () => {
const html = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body class="email-body">
<table>
<tr>
<td>Preview content</td>
</tr>
</table>
</body>
</html>
`;
expect(extractEmailBodyFragment(html)).toBe(
"<table>\n <tr>\n <td>Preview content</td>\n </tr>\n </table>"
);
});
test("removes document-level tags from rendered survey email markup", () => {
const fragment = extractEmailBodyFragment(`
<!DOCTYPE html>
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body>
<table>
<tr>
<td>Which fruits do you like</td>
</tr>
</table>
</body>
</html>
`);
expect(fragment).toBe(
"<table>\n <tr>\n <td>Which fruits do you like</td>\n </tr>\n </table>"
);
expect(fragment).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
});
test("falls back to the original markup when no body tag exists", () => {
expect(extractEmailBodyFragment("<div>Preview content</div>")).toBe("<div>Preview content</div>");
});
test("removes React server markers from rendered fragments", () => {
expect(extractEmailBodyFragment("<body><!--$--><div>Preview content</div><!--/$--></body>")).toBe(
"<div>Preview content</div>"
);
});
});
@@ -1,12 +1,10 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { toJsWorkspaceStateSurvey } from "@/lib/survey/client-utils";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
const t = await getTranslate();
@@ -19,9 +17,12 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const styling = getStyling(workspace, toJsWorkspaceStateSurvey(survey));
const styling = getStyling(workspace, survey);
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const htmlCleaned = html.toString().replace(doctype, "");
return extractEmailBodyFragment(html.toString());
return htmlCleaned;
};
@@ -1,11 +0,0 @@
const EMAIL_DOCTYPE_PATTERN = /<!DOCTYPE[^>]*>/i;
const EMAIL_BODY_PATTERN = /<body\b[^>]*>([\s\S]*?)<\/body>/i;
const EMAIL_REACT_SERVER_MARKER_PATTERN = /<!--\/?\$-->/g;
export const extractEmailBodyFragment = (html: string): string => {
const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
};
@@ -1105,21 +1105,6 @@ describe("getSurveySummary", () => {
expect.objectContaining({ responseIds: expect.any(Array) })
);
});
test("does not pass responseIds for date-only filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = {
createdAt: {
min: new Date("2024-01-01T00:00:00.000Z"),
max: new Date("2024-01-31T23:59:59.999Z"),
},
};
await getSurveySummary(mockSurveyId, filterCriteria);
expect(getDisplayCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, {
createdAt: filterCriteria.createdAt,
});
});
});
describe("getResponsesForSummary", () => {
@@ -999,7 +999,7 @@ export const getSurveySummary = reactCache(
const elements = getElementsFromBlocks(survey.blocks);
const batchSize = 5000;
const hasFilter = Object.keys(filterCriteria ?? {}).some((filterKey) => filterKey !== "createdAt");
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
// Use cursor-based pagination instead of count + offset to avoid expensive queries
const responses: TSurveySummaryResponse[] = [];
@@ -4,12 +4,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/workspaces/[workspaceId]/s
import { SummaryPage } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getSurveySummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import {
DEFAULT_LOCALE,
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
} from "@/lib/constants";
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
@@ -69,6 +64,7 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
@@ -77,7 +73,6 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isStorageConfigured={IS_STORAGE_CONFIGURED}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
}>
<SurveyAnalysisNavigation survey={survey} activeId="summary" />
@@ -42,25 +42,18 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
],
});
const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
const result = await getResponseDownloadFile(
parsedInput.surveyId,
parsedInput.format,
parsedInput.filterCriteria
);
capturePostHogEvent(
ctx.user.id,
"responses_exported",
{
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
workspace_id: workspaceId,
},
{ organizationId, workspaceId }
);
capturePostHogEvent(ctx.user.id, "responses_exported", {
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
});
return result;
});
@@ -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 { useSurvey } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/context/survey-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
@@ -16,8 +15,12 @@ import {
} from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
export const SurveyStatusDropdown = () => {
const { survey } = useSurvey();
interface SurveyStatusDropdownProps {
updateLocalSurveyStatus?: (status: TSurvey["status"]) => void;
survey: TSurvey;
}
export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: SurveyStatusDropdownProps) => {
const { t } = useTranslation();
const router = useRouter();
const isScheduled = survey.status === "paused" && survey.publishOn !== null;
@@ -39,6 +42,10 @@ export const SurveyStatusDropdown = () => {
toast.success(toastMessage);
}
if (updateLocalSurveyStatus) {
updateLocalSurveyStatus(resultingStatus);
}
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
@@ -1,160 +0,0 @@
import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
vi.mock("server-only", () => ({}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/jwt", () => ({
verifyAccountDeletionSsoReauthIntent: vi.fn(),
}));
vi.mock("@/modules/account/lib/account-deletion", () => ({
deleteUserWithAccountDeletionAuthorization: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: vi.fn(),
}));
const mockGetServerSession = vi.mocked(getServerSession);
const mockLoggerError = vi.mocked(logger.error);
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
const intent = {
id: "intent-id",
email: "delete-user@example.com",
provider: "google",
providerAccountId: "google-account-id",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-id/settings/profile",
userId: "user-id",
};
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
beforeEach(() => {
vi.clearAllMocks();
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockGetServerSession.mockResolvedValue({
user: {
email: intent.email,
id: intent.userId,
},
} as any);
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
oldUser: { id: intent.userId } as any,
});
mockQueueAuditEventBackground.mockResolvedValue(undefined);
});
test("returns login without deleting when the callback has no intent", async () => {
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
"/auth/login"
);
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
});
test("deletes the account after a completed SSO reauthentication", async () => {
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
confirmationEmail: intent.email,
userEmail: intent.email,
userId: intent.userId,
});
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
action: "deleted",
targetType: "user",
userId: intent.userId,
userType: "user",
targetId: intent.userId,
organizationId: "unknown",
oldObject: { id: intent.userId },
status: "success",
});
});
test("does not delete when the callback session does not match the intent user", async () => {
mockGetServerSession.mockResolvedValue({
user: {
email: "other@example.com",
id: "other-user-id",
},
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(AuthorizationError) },
"Failed to complete account deletion after SSO reauth"
);
});
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(Error) },
"Failed to complete account deletion after SSO reauth"
);
});
test("falls back to login when the intent return URL is not allowed", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
...intent,
returnToUrl: "https://evil.example/settings/profile",
});
mockGetServerSession.mockResolvedValue({
user: {
email: "other@example.com",
id: "other-user-id",
},
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
});
});
@@ -1,82 +0,0 @@
import "server-only";
import { getServerSession } from "next-auth";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
type TAccountDeletionSsoCompleteSearchParams = {
intent?: string | string[];
};
const getIntentToken = (intent: string | string[] | undefined) => {
if (Array.isArray(intent)) {
return intent[0];
}
return intent;
};
const getSafeRedirectPath = (returnToUrl: string) => {
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
if (!validatedReturnToUrl) {
return "/auth/login";
}
const parsedReturnToUrl = new URL(validatedReturnToUrl);
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
};
const getPostDeletionRedirectPath = () =>
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
intent,
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
const intentToken = getIntentToken(intent);
let redirectPath = "/auth/login";
if (!intentToken) {
return redirectPath;
}
try {
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
redirectPath = getSafeRedirectPath(verifiedIntent.returnToUrl);
const session = await getServerSession(authOptions);
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
}
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
confirmationEmail: verifiedIntent.email,
userEmail: session.user.email,
userId: session.user.id,
});
redirectPath = getPostDeletionRedirectPath();
await queueAuditEventBackground({
action: "deleted",
targetType: "user",
userId: session.user.id,
userType: "user",
targetId: session.user.id,
organizationId: UNKNOWN_DATA,
oldObject: oldUser,
status: "success",
});
logger.info({ userId: session.user.id }, "Completed account deletion after SSO reauth");
} catch (error) {
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
}
return redirectPath;
};
@@ -1,10 +0,0 @@
import { redirect } from "next/navigation";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
export default async function AccountDeletionSsoReauthCompletePage({
searchParams,
}: {
searchParams: Promise<{ intent?: string | string[] }>;
}) {
redirect(await completeAccountDeletionSsoReauthenticationAndGetRedirectPath(await searchParams));
}
@@ -1,14 +0,0 @@
import { NextRequest } from "next/server";
import { authorizeTraefikRequest } from "@/modules/traefik-auth/service";
const handler = async (request: NextRequest): Promise<Response> => {
return await authorizeTraefikRequest(request);
};
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
export const HEAD = handler;
export const OPTIONS = handler;
@@ -185,20 +185,4 @@ describe("auth route audit logging", () => {
})
);
});
test("does not log a completed sign-in for the intermediate SSO recovery verification step", async () => {
const authOptions = await getWrappedAuthOptions("req-sso-recovery");
const user = {
id: "user_4",
email: "user4@example.com",
authFlowPurpose: "sso_recovery",
};
const account = { provider: "token" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
});
});
@@ -26,12 +26,6 @@ const getAuthMethod = (account: Account | null) => {
return "unknown";
};
const isSsoRecoveryVerificationFlow = (account: Account | null, user: User | AdapterUser) =>
account?.provider === "token" &&
"authFlowPurpose" in user &&
typeof user.authFlowPurpose === "string" &&
user.authFlowPurpose === "sso_recovery";
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -123,10 +117,6 @@ const handler = async (req: Request, ctx: any) => {
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
if (isSsoRecoveryVerificationFlow(account, user)) {
return;
}
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
@@ -1,67 +0,0 @@
import { getServerSession } from "next-auth";
import { NextResponse } from "next/server";
import { logger } from "@formbricks/logger";
import { verifySsoRelinkIntent } from "@/lib/jwt";
import { deleteSessionBySessionToken } from "@/modules/auth/lib/auth-session-repository";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
NEXT_AUTH_SESSION_COOKIE_NAMES,
getSessionTokenFromCookieHeader,
} from "@/modules/auth/lib/session-cookie";
import { completeSsoRecovery, getSsoRecoveryFailureRedirectUrl } from "@/modules/ee/sso/lib/sso-recovery";
const clearSessionCookies = (response: NextResponse) => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
response.cookies.set({
name: cookieName,
value: "",
expires: new Date(0),
path: "/",
secure: cookieName.startsWith("__Secure-"),
});
}
};
const buildFailedRecoveryResponse = async (request: Request, callbackUrl?: string) => {
const response = NextResponse.redirect(getSsoRecoveryFailureRedirectUrl(callbackUrl));
clearSessionCookies(response);
const sessionToken = getSessionTokenFromCookieHeader(request.headers.get("cookie"));
if (!sessionToken) {
return response;
}
try {
await deleteSessionBySessionToken(sessionToken);
} catch (error) {
logger.error(error, "Failed to delete SSO recovery session after recovery completion error");
}
return response;
};
export const GET = async (request: Request) => {
const url = new URL(request.url);
const intentToken = url.searchParams.get("intent");
if (!intentToken) {
return NextResponse.redirect(getSsoRecoveryFailureRedirectUrl());
}
try {
const session = await getServerSession(authOptions);
const callbackUrl = await completeSsoRecovery({
intentToken,
sessionUserId: session?.user.id,
});
return NextResponse.redirect(callbackUrl);
} catch {
try {
const intent = verifySsoRelinkIntent(intentToken);
return await buildFailedRecoveryResponse(request, intent.callbackUrl);
} catch {
return await buildFailedRecoveryResponse(request);
}
}
};
@@ -1,13 +0,0 @@
import { Prisma } from "@prisma/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
return Array.isArray(error.meta?.target) && error.meta.target.includes("singleUseId");
};
@@ -1,116 +0,0 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { TResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
type TSingleUseResponseInput = Pick<TResponseInput, "singleUseId" | "meta">;
type TValidateSingleUseResponseInputResult = { singleUseId: string } | { response: Response } | null;
export const validateSingleUseResponseInput = (
survey: TSurvey,
environmentId: string,
responseInput: TSingleUseResponseInput
): TValidateSingleUseResponseInputResult => {
if (survey.type !== "link" || !survey.singleUse?.enabled) {
return null;
}
if (!ENCRYPTION_KEY) {
logger.error({ surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
return {
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
};
}
if (!responseInput.singleUseId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (!responseInput.meta?.url) {
return {
response: responses.badRequestResponse(
"Missing or invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
let url: URL;
try {
url = new URL(responseInput.meta.url);
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
error: error instanceof Error ? error.message : "Unknown error occurred",
},
true
),
};
}
const suId = url.searchParams.get("suId");
const suToken = url.searchParams.get("suToken");
if (!suId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
let canonicalSingleUseId: string | null = null;
try {
canonicalSingleUseId = validateSurveySingleUseLinkParams({
surveyId: survey.id,
suId,
suToken,
isEncrypted: survey.singleUse.isEncrypted,
decrypt: (encryptedSingleUseId: string) => symmetricDecrypt(encryptedSingleUseId, ENCRYPTION_KEY),
});
} catch (error) {
logger.error({ error, surveyId: survey.id, environmentId }, "Failed to validate single-use id");
}
if (!canonicalSingleUseId || canonicalSingleUseId !== responseInput.singleUseId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
return { singleUseId: canonicalSingleUseId };
};
@@ -88,16 +88,6 @@ export const GET = async (req: Request) => {
integration_type: "googleSheets",
organization_id: organizationId,
});
capturePostHogEvent(
session.user.id,
"integration_connected",
{
integration_type: "googleSheets",
organization_id: organizationId,
workspace_id: workspaceId,
},
{ organizationId, workspaceId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
+3 -125
View File
@@ -1,15 +1,9 @@
import { NextRequest } from "next/server";
import { describe, expect, test, vi } from "vitest";
import { TAPIKeyWorkspacePermission } from "@formbricks/types/auth";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { authenticateRequest, handleErrorResponse } from "./auth";
import { authenticateRequest } from "./auth";
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
getApiKeyWithPermissions: vi.fn(),
@@ -171,7 +165,7 @@ describe("authenticateRequest", () => {
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
const result = await authenticateRequest(request);
expect(result).toEqual({
type: "apiKey",
workspacePermissions: [],
@@ -197,7 +191,7 @@ describe("authenticateRequest", () => {
apiKeyWorkspaces: [],
} as any);
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
const result = await authenticateRequest(request);
expect(result).toEqual({
type: "apiKey",
@@ -208,120 +202,4 @@ describe("authenticateRequest", () => {
});
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("fbk_valid_bearer_key");
});
test("authenticates a valid API key with no environment permissions when explicitly allowed", async () => {
const request = new NextRequest("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
organizationAccess: "all" as const,
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyWorkspaces: [],
};
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
expect(result).toEqual({
type: "apiKey",
workspacePermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: "all",
});
});
test("authenticates a read-only organization API key with no environment permissions", async () => {
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
headers: { "x-api-key": "read-only-org-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Read-only Organization API Key",
apiKeyWorkspaces: [],
};
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
expect(result).toEqual({
type: "apiKey",
workspacePermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
});
});
describe("handleErrorResponse", () => {
test("returns 401 notAuthenticated for 'NotAuthenticated' message", async () => {
const response = handleErrorResponse(new Error("NotAuthenticated"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("not_authenticated");
});
test("returns 401 unauthorized for 'Unauthorized' message", async () => {
const response = handleErrorResponse(new Error("Unauthorized"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("unauthorized");
});
test("returns 409 conflict for UniqueConstraintError", async () => {
const response = handleErrorResponse(new UniqueConstraintError("Action with name foo already exists"));
expect(response.status).toBe(409);
const body = await response.json();
expect(body.code).toBe("conflict");
expect(body.message).toBe("Action with name foo already exists");
});
test("returns 400 badRequest for DatabaseError", async () => {
const response = handleErrorResponse(new DatabaseError("db boom"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("db boom");
});
test("returns 400 badRequest for InvalidInputError", async () => {
const response = handleErrorResponse(new InvalidInputError("bad input"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("bad input");
});
test("returns 400 badRequest for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(400);
});
test("returns 500 internalServerError for unknown errors", async () => {
const response = handleErrorResponse(new Error("something else"));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.message).toBe("Some error occurred");
});
});
+4 -18
View File
@@ -1,22 +1,11 @@
import { NextRequest } from "next/server";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import {
type AuthenticateApiKeyOptions,
authenticateApiKeyFromHeaders,
} from "@/modules/api/lib/api-key-auth";
import { authenticateApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
export const authenticateRequest = async (
request: NextRequest,
options: AuthenticateApiKeyOptions = {}
): Promise<TAuthenticationApiKey | null> => {
return await authenticateApiKeyFromHeaders(request.headers, options);
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
return await authenticateApiKeyFromHeaders(request.headers);
};
export const handleErrorResponse = (error: any): Response => {
@@ -26,9 +15,6 @@ export const handleErrorResponse = (error: any): Response => {
case "Unauthorized":
return responses.unauthorizedResponse();
default:
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
@@ -1,60 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getContactByUserId } from "./contact";
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
},
},
}));
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn((fn: Function) => fn),
};
});
const workspaceId = "test-workspace-id";
const userId = "test-user-id";
const contact = {
id: "test-contact-id",
createdAt: new Date(),
updatedAt: new Date(),
workspaceId,
};
describe("getContactByUserId", () => {
test("returns the first contact whose userId attribute exactly matches in the workspace", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: contact.id });
const result = await getContactByUserId(workspaceId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
workspaceId,
},
value: userId,
},
},
},
select: { id: true },
});
expect(result).toEqual({ id: contact.id });
});
test("returns null when no contact matches", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const result = await getContactByUserId(workspaceId, userId);
expect(result).toBeNull();
});
});
@@ -2,12 +2,7 @@ import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TDisplayCreateInput } from "@formbricks/types/displays";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getContactByUserId } from "./contact";
import { createDisplay } from "./display";
@@ -83,7 +78,6 @@ const mockSurvey = {
id: surveyId,
name: "Test Survey",
workspaceId,
status: "inProgress",
} as any;
describe("createDisplay", () => {
@@ -186,17 +180,6 @@ describe("createDisplay", () => {
expect(prisma.display.create).not.toHaveBeenCalled();
});
test.each(["draft", "paused", "completed"])(
"should throw InvalidInputError when survey status is %s",
async (status) => {
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
expect(prisma.display.create).not.toHaveBeenCalled();
}
);
test("should throw DatabaseError on other Prisma known request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getContactByUserId } from "./contact";
@@ -41,10 +41,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
throw new ResourceNotFoundError("Survey", surveyId);
}
if (survey.status !== "inProgress") {
throw new InvalidInputError("Survey is not accepting submissions");
}
const display = await prisma.display.create({
data: {
survey: {
@@ -1,6 +1,6 @@
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -72,12 +72,6 @@ export const POST = withV1ApiWrapper({
return {
response: responses.notFoundResponse("Survey", inputValidation.data.surveyId),
};
} else if (error instanceof InvalidInputError) {
return {
response: responses.forbiddenResponse(error.message, true, {
surveyId: inputValidation.data.surveyId,
}),
};
} else {
logger.error({ error, url: req.url }, "Error in POST /api/v1/client/[workspaceId]/displays");
return {
@@ -72,7 +72,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
select: {
id: true,
welcomeCard: true,
// name intentionally omitted — internal label not needed by the SDK
name: true,
questions: true,
blocks: true,
variables: true,
@@ -99,13 +99,13 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
styling: true,
status: true,
recaptcha: true,
// Fetch only what's needed to compute the minimal segment shape.
// Titles, descriptions, and filter conditions are evaluated server-side
// and must not be sent to the browser.
segment: {
select: {
id: true,
filters: true,
include: {
surveys: {
select: {
id: true,
},
},
},
},
recontactDays: true,
@@ -135,28 +135,10 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
throw new ResourceNotFoundError("workspace", workspaceId);
}
// Transform surveys using the shared utility, then replace the segment with
// the minimal public shape (id + hasFilters). We null out segment before
// calling transformPrismaSurvey because that function expects a surveys[]
// relation on the segment object (used by the management API), which we
// intentionally don't fetch here.
const transformedSurveys = workspaceData.surveys.map((survey) => {
const minimalSegment = survey.segment
? {
id: survey.segment.id,
hasFilters:
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
}
: null;
const { segment: _segment, ...surveyWithoutSegment } = survey;
const transformed = transformPrismaSurvey<TJsWorkspaceStateSurvey>({
...surveyWithoutSegment,
segment: null,
});
return { ...transformed, segment: minimalSegment };
});
// Transform surveys using existing utility
const transformedSurveys = workspaceData.surveys.map((survey) =>
transformPrismaSurvey<TJsWorkspaceStateSurvey>(survey)
);
return {
workspace: {
@@ -172,7 +154,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
},
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
actionClasses: workspaceData.actionClasses,
actionClasses: workspaceData.actionClasses as TJsWorkspaceStateActionClass[],
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
@@ -6,17 +6,11 @@ import { TJsWorkspaceStateWorkspaceSetting } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { WorkspaceStateData, getWorkspaceStateData } from "./data";
import { getWorkspaceState } from "./environmentState";
vi.mock("server-only", () => ({}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
vi.mock("@/modules/storage/utils", () => ({ resolveStorageUrlsInObject: vi.fn((obj: unknown) => obj) }));
vi.mock("@/modules/survey/lib/utils", () => ({ transformPrismaSurvey: vi.fn() }));
// Mock dependencies
vi.mock("@/lib/cache", () => ({
cache: {
@@ -29,9 +23,6 @@ vi.mock("@formbricks/database", () => ({
workspace: {
update: vi.fn(),
},
project: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
@@ -72,10 +63,6 @@ vi.mock("@/lib/posthog", () => ({
capturePostHogEvent: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn().mockResolvedValue("mock-org-id"),
}));
// Mock @formbricks/cache
vi.mock("@formbricks/cache", () => ({
createCacheKey: {
@@ -169,7 +156,6 @@ describe("getWorkspaceState", () => {
// Default mocks for successful retrieval
vi.mocked(getWorkspaceStateData).mockResolvedValue(mockWorkspaceStateData);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("mock-org-id");
});
afterEach(() => {
@@ -342,18 +328,11 @@ describe("getWorkspaceState", () => {
await getWorkspaceState(workspaceId);
expect(capturePostHogEvent).toHaveBeenCalledWith(
workspaceId,
"app_connected",
{
num_surveys: 1,
num_code_actions: 1,
num_no_code_actions: 1,
organization_id: "mock-org-id",
workspace_id: workspaceId,
},
{ organizationId: "mock-org-id", workspaceId }
);
expect(capturePostHogEvent).toHaveBeenCalledWith(workspaceId, "app_connected", {
num_surveys: 1,
num_code_actions: 1,
num_no_code_actions: 1,
});
});
test("should not capture app_connected event when app setup already completed", async () => {
@@ -9,7 +9,6 @@ import {
import { cache } from "@/lib/cache";
import { IS_RECAPTCHA_CONFIGURED, POSTHOG_KEY, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspaceStateData } from "./data";
/**
@@ -38,19 +37,11 @@ export const getWorkspaceState = async (
});
if (POSTHOG_KEY) {
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
capturePostHogEvent(
workspaceId,
"app_connected",
{
num_surveys: surveys.length,
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
organization_id: organizationId ?? "",
workspace_id: workspaceId,
},
organizationId ? { organizationId, workspaceId } : undefined
);
capturePostHogEvent(workspaceId, "app_connected", {
num_surveys: surveys.length,
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
});
}
}
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses";
import { getOrganization } from "@/lib/organization/service";
@@ -10,8 +10,6 @@ import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
vi.mock("server-only", () => ({}));
let mockIsFormbricksCloud = false;
vi.mock("@/lib/constants", () => ({
@@ -145,16 +143,6 @@ describe("createResponse", () => {
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(DatabaseError);
});
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(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
});
test("should throw original error on other Prisma errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
@@ -2,14 +2,10 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import {
isPrismaKnownRequestError,
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
@@ -126,11 +122,7 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -1,11 +1,10 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateSingleUseResponseInput } from "@/app/api/client/[workspaceId]/responses/lib/single-use";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -95,7 +94,10 @@ export const POST = withV1ApiWrapper({
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
@@ -131,22 +133,6 @@ export const POST = withV1ApiWrapper({
};
}
if (survey.status !== "inProgress") {
return {
response: responses.forbiddenResponse("Survey is not accepting submissions", true, {
surveyId: survey.id,
}),
};
}
const singleUseValidationResult = validateSingleUseResponseInput(survey, workspaceId, responseInputData);
if (singleUseValidationResult) {
if ("response" in singleUseValidationResult) {
return { response: singleUseValidationResult.response };
}
responseInputData.singleUseId = singleUseValidationResult.singleUseId;
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
@@ -188,10 +174,6 @@ export const POST = withV1ApiWrapper({
return {
response: responses.badRequestResponse(error.message),
};
} else if (error instanceof UniqueConstraintError) {
return {
response: responses.conflictResponse(error.message, undefined, true),
};
} else {
logger.error({ error, url: req.url }, "Error creating response");
return {
@@ -8,7 +8,6 @@ import { getOrganization } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
import { getSignedUrlForUpload } from "@/modules/storage/service";
@@ -96,17 +95,6 @@ export const POST = withV1ApiWrapper({
};
}
try {
await applyRateLimit(rateLimitConfigs.storage.uploadPerWorkspace, workspaceId);
} catch (error) {
return {
response: responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Rate limit exceeded",
true
),
};
}
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id);
const maxFileUploadSize = isBiggerFileUploadAllowed
? MAX_FILE_UPLOAD_SIZES.big
@@ -4,7 +4,7 @@ import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
@@ -79,15 +79,11 @@ export const GET = withV1ApiWrapper({
}
const email = await getEmail(key.access_token);
// Preserve existing integration data (survey-to-table mappings) when re-authorizing
const existingIntegration = await getIntegrationByType(workspaceId, "airtable");
const existingData = existingIntegration?.config?.data ?? [];
const airtableIntegrationInput = {
type: "airtable" as const,
type: "airtable" as "airtable",
config: {
key,
data: existingData,
data: [],
email,
},
};
@@ -95,16 +91,10 @@ export const GET = withV1ApiWrapper({
try {
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "airtable",
organization_id: organizationId,
workspace_id: workspaceId,
},
{ organizationId, workspaceId }
);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "airtable",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
@@ -1,7 +1,8 @@
import * as z from "zod";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getAirtableToken, getTables } from "@/lib/airtable/service";
import { getTables } from "@/lib/airtable/service";
import { getIntegrationByType } from "@/lib/integration/service";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
@@ -35,20 +36,15 @@ export const GET = withV1ApiWrapper({
};
}
const integration = await getIntegrationByType(workspaceId, "airtable");
const integration = (await getIntegrationByType(workspaceId, "airtable")) as TIntegrationAirtable;
if (!integration) {
return {
response: responses.notFoundResponse("Integration not found", null),
response: responses.notFoundResponse("Integration not found", workspaceId),
};
}
// Use getAirtableToken to ensure the access token is refreshed if expired
const freshAccessToken = await getAirtableToken(workspaceId);
const tables = await getTables(
{ ...integration.config.key, access_token: freshAccessToken },
baseId.data
);
const tables = await getTables(integration.config.key, baseId.data);
return {
response: responses.successResponse(tables),
};
@@ -1,5 +1,5 @@
import { logger } from "@formbricks/logger";
import { TIntegrationNotionInput } from "@formbricks/types/integration/notion";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import {
@@ -95,7 +95,7 @@ export const GET = withV1ApiWrapper({
const existingIntegration = await getIntegrationByType(workspaceId, "notion");
if (existingIntegration) {
notionIntegration.config.data = existingIntegration.config.data;
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
}
const result = await createOrUpdateIntegration(workspaceId, notionIntegration);
@@ -103,16 +103,10 @@ export const GET = withV1ApiWrapper({
if (result) {
try {
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "notion",
organization_id: organizationId,
workspace_id: workspaceId,
},
{ organizationId, workspaceId }
);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "notion",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
}
@@ -110,16 +110,10 @@ export const GET = withV1ApiWrapper({
if (result) {
try {
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "slack",
organization_id: organizationId,
workspace_id: workspaceId,
},
{ organizationId, workspaceId }
);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "slack",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
}
@@ -1,6 +1,6 @@
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { DatabaseError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -84,11 +84,6 @@ export const POST = withV1ApiWrapper({
response: responses.successResponse(actionClass),
};
} catch (error) {
if (error instanceof UniqueConstraintError) {
return {
response: responses.conflictResponse(error.message),
};
}
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
@@ -170,20 +170,6 @@ const handleSessionAuthentication = async () => {
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
select: {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
},
});
return Response.json(user);
@@ -3,7 +3,7 @@ import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { generateSurveySingleUseLinkParamsList } from "@/lib/utils/single-use-surveys";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const GET = withV1ApiWrapper({
@@ -56,22 +56,13 @@ export const GET = withV1ApiWrapper({
};
}
const singleUseLinkParams = generateSurveySingleUseLinkParamsList(
limit,
survey.id,
survey.singleUse.isEncrypted
);
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
const publicDomain = getPublicDomain();
// map single use ids to survey links
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
const surveyLink = new URL(`${publicDomain}/s/${survey.id}`);
surveyLink.searchParams.set("suId", suId);
if (suToken) {
surveyLink.searchParams.set("suToken", suToken);
}
return surveyLink.toString();
});
const surveyLinks = singleUseIds.map(
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
);
return {
response: responses.successResponse(surveyLinks),
@@ -23,7 +23,6 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
allowOrganizationOnlyApiKey: true,
handler: async ({ req, authentication }) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
@@ -43,7 +43,7 @@ describe("doesContactExist", () => {
});
});
test("should return false if contact does not exist in the workspace", async () => {
test("should return false if contact does not exist", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const result = await doesContactExist(contactId);
@@ -1,12 +1,7 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { TDisplayCreateInputV2 } from "../types/display";
import { doesContactExist } from "./contact";
@@ -71,7 +66,6 @@ const mockSurvey = {
id: surveyId,
name: "Test Survey",
workspaceId,
status: "inProgress",
} as any;
describe("createDisplay", () => {
@@ -114,7 +108,7 @@ describe("createDisplay", () => {
expect(result).toEqual(mockDisplayWithoutContact); // Changed this line
});
test("should create a display without contact if contact does not exist in the workspace", async () => {
test("should create a display even if contact does not exist", async () => {
vi.mocked(doesContactExist).mockResolvedValue(false);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection
@@ -155,17 +149,6 @@ describe("createDisplay", () => {
expect(prisma.display.create).not.toHaveBeenCalled();
});
test.each(["draft", "paused", "completed"])(
"should throw InvalidInputError when survey status is %s",
async (status) => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
expect(prisma.display.create).not.toHaveBeenCalled();
}
);
test("should throw DatabaseError on other Prisma known request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
TDisplayCreateInputV2,
ZDisplayCreateInputV2,
@@ -26,10 +26,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
throw new ResourceNotFoundError("Survey", surveyId);
}
if (survey.status !== "inProgress") {
throw new InvalidInputError("Survey is not accepting submissions");
}
const display = await prisma.display.create({
data: {
survey: {
@@ -1,4 +1,4 @@
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
TDisplayCreateInputV2,
ZDisplayCreateInputV2,
@@ -52,6 +52,7 @@ export const OPTIONS = async (): Promise<Response> => {
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(params.workspaceId);
if (!resolved) {
@@ -87,12 +88,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
return responses.notFoundResponse("Survey", displayInputData.surveyId, true);
}
if (error instanceof InvalidInputError) {
return responses.forbiddenResponse(error.message, true, {
surveyId: displayInputData.surveyId,
});
}
const response = responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
reportApiError({
request,
@@ -13,7 +13,6 @@ vi.mock("@formbricks/database", () => ({
}));
const contactId = "test-contact-id";
const workspaceId = "test-workspace-id";
const mockContact = {
id: contactId,
attributes: [
@@ -33,10 +32,10 @@ describe("getContact", () => {
mockContact as unknown as Awaited<ReturnType<typeof prisma.contact.findUnique>>
);
const result = await getContact(contactId, workspaceId);
const result = await getContact(contactId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: contactId, workspaceId },
where: { id: contactId },
select: {
id: true,
attributes: {
@@ -56,10 +55,10 @@ describe("getContact", () => {
test("should return null when contact is not found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
const result = await getContact(contactId, workspaceId);
const result = await getContact(contactId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: contactId, workspaceId },
where: { id: contactId },
select: {
id: true,
attributes: {
@@ -2,16 +2,9 @@ import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
type TContactAttributeResult = {
attributeKey: {
key: string;
};
value: string;
};
export const getContact = reactCache(async (contactId: string, workspaceId: string) => {
export const getContact = reactCache(async (contactId: string) => {
const contact = await prisma.contact.findUnique({
where: { id: contactId, workspaceId },
where: { id: contactId },
select: {
id: true,
attributes: {
@@ -27,13 +20,10 @@ export const getContact = reactCache(async (contactId: string, workspaceId: stri
return null;
}
const contactAttributes = contact.attributes.reduce(
(acc: TContactAttributes, attr: TContactAttributeResult) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
},
{}
);
const contactAttributes = contact.attributes.reduce<TContactAttributes>((acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
}, {});
return {
id: contact.id,
@@ -239,51 +239,6 @@ describe("createResponse V2", () => {
const result = await createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient);
expect(result.tags).toEqual([mockTag]);
});
test("should create response with contact when contact belongs to the workspace", async () => {
const responseInputWithContact = {
...mockResponseInput,
contactId,
};
const result = await createResponse(
responseInputWithContact,
mockTx as unknown as Prisma.TransactionClient
);
expect(getContact).toHaveBeenCalledWith(contactId, workspaceId);
expect(mockTx.response.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
contact: { connect: { id: contactId } },
contactAttributes: mockContact.attributes,
}),
})
);
expect(result.contact).toEqual({
id: contactId,
userId,
});
});
test("should create response without contact when contact is not found in the workspace", async () => {
vi.mocked(getContact).mockResolvedValue(null);
const responseInputWithContact = {
...mockResponseInput,
contactId,
};
const result = await createResponse(
responseInputWithContact,
mockTx as unknown as Prisma.TransactionClient
);
const createArgs = mockTx.response.create.mock.calls[0][0];
expect(getContact).toHaveBeenCalledWith(contactId, workspaceId);
expect(createArgs.data).not.toHaveProperty("contact");
expect(createArgs.data).not.toHaveProperty("contactAttributes");
expect(result.contact).toBeNull();
});
});
describe("createResponseWithQuotaEvaluation V2", () => {
@@ -6,10 +6,6 @@ import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@fo
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import {
isPrismaKnownRequestError,
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { responseSelection } from "@/app/api/v1/client/[workspaceId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/types/response";
import { getOrganization } from "@/lib/organization/service";
@@ -22,7 +18,7 @@ import { getContact } from "./contact";
export const createResponseWithQuotaEvaluation = async (
responseInput: TResponseInputV2
): Promise<TResponseWithQuotaFull> => {
const txResponse = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const txResponse = await prisma.$transaction(async (tx) => {
const response = await createResponse(responseInput, tx);
const quotaResult = await evaluateResponseQuotas({
@@ -107,7 +103,7 @@ export const createResponse = async (
}
if (contactId) {
contact = await getContact(contactId, workspaceId);
contact = await getContact(contactId);
}
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
@@ -134,9 +130,12 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
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);
@@ -9,7 +9,6 @@ import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/ty
import { responses } from "@/app/lib/api/response";
import { symmetricDecrypt } from "@/lib/crypto";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { generateSurveySingleUseSignature } from "@/lib/utils/single-use-surveys";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
vi.mock("@/lib/i18n/utils", () => ({
@@ -26,7 +25,6 @@ vi.mock("@/app/lib/api/response", () => ({
responses: {
badRequestResponse: vi.fn((message) => new Response(message, { status: 400 })),
notFoundResponse: vi.fn((message) => new Response(message, { status: 404 })),
forbiddenResponse: vi.fn((message) => new Response(message, { status: 403 })),
},
}));
@@ -54,11 +52,6 @@ vi.mock("@/lib/crypto", () => ({
vi.mock("@/lib/constants", () => ({
ENCRYPTION_KEY: "test-key",
}));
vi.mock("@/lib/env", () => ({
env: {
ENCRYPTION_KEY: "test-key",
},
}));
const mockSurvey: TSurvey = {
id: "survey-1",
@@ -97,9 +90,9 @@ const mockSurvey: TSurvey = {
showLanguageSwitch: false,
blocks: [],
isCaptureIpEnabled: false,
isAutoProgressingEnabled: true,
metadata: {},
slug: null,
isAutoProgressingEnabled: true,
};
const mockResponseInput: TResponseInputV2 = {
@@ -119,7 +112,6 @@ const mockBillingData: TOrganizationBilling = {
usageCycleAnchor: new Date(),
stripeCustomerId: "mock-stripe-customer-id",
};
const validSingleUseId = "cm8f4x9mm0001gx9h5b7d7h3q";
describe("checkSurveyValidity", () => {
beforeEach(() => {
@@ -141,19 +133,6 @@ describe("checkSurveyValidity", () => {
);
});
test.each(["draft", "paused", "completed"] as const)(
"should return forbiddenResponse when survey status is %s",
async (status) => {
const survey = { ...mockSurvey, status } as TSurvey;
const result = await checkSurveyValidity(survey, "ws-1", mockResponseInput);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith("Survey is not accepting submissions", true, {
surveyId: mockSurvey.id,
});
}
);
test("should return null if recaptcha is not enabled", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: false, threshold: 0.5 }, workspaceId: "ws-1" };
const result = await checkSurveyValidity(survey, "ws-1", mockResponseInput);
@@ -332,7 +311,7 @@ describe("checkSurveyValidity", () => {
});
});
test("should return badRequestResponse if singleUse is enabled, not encrypted, and suId present but no suToken", async () => {
test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false }, workspaceId: "ws-1" };
const url = "https://example.com/?suId=su-1";
const result = await checkSurveyValidity(survey, "ws-1", {
@@ -340,35 +319,16 @@ describe("checkSurveyValidity", () => {
singleUseId: "su-1",
meta: { url },
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
workspaceId: "ws-1",
});
});
test("should return null if singleUse is enabled, not encrypted, and signed suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false }, workspaceId: "ws-1" };
const suToken = generateSurveySingleUseSignature(survey.id, "su-1");
const url = `https://example.com/?suId=su-1&suToken=${suToken}`;
const responseInput = {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
};
const result = await checkSurveyValidity(survey, "ws-1", responseInput);
expect(result).toBeNull();
expect(responseInput.singleUseId).toBe("su-1");
});
test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true }, workspaceId: "ws-1" };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue(validSingleUseId);
vi.mocked(symmetricDecrypt).mockReturnValue("su-1");
const _resultEncryptedMatch = await checkSurveyValidity(survey, "ws-1", {
...mockResponseInput,
singleUseId: validSingleUseId,
singleUseId: "su-1",
meta: { url },
});
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
@@ -7,7 +7,6 @@ import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
export const RECAPTCHA_VERIFICATION_ERROR_CODE = "recaptcha_verification_failed";
@@ -27,12 +26,6 @@ export const checkSurveyValidity = async (
);
}
if (survey.status !== "inProgress") {
return responses.forbiddenResponse("Survey is not accepting submissions", true, {
surveyId: survey.id,
});
}
if (survey.type === "link" && survey.singleUse?.enabled) {
if (!responseInput.singleUseId) {
return responses.badRequestResponse("Missing single use id", {
@@ -59,7 +52,6 @@ export const checkSurveyValidity = async (
});
}
const suId = url.searchParams.get("suId");
const suToken = url.searchParams.get("suToken");
if (!suId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
@@ -67,27 +59,20 @@ export const checkSurveyValidity = async (
});
}
let canonicalSingleUseId: string | null = null;
try {
canonicalSingleUseId = validateSurveySingleUseLinkParams({
surveyId: survey.id,
suId,
suToken,
isEncrypted: survey.singleUse.isEncrypted,
decrypt: (encryptedSingleUseId: string) => symmetricDecrypt(encryptedSingleUseId, ENCRYPTION_KEY),
});
} catch (error) {
logger.error({ error, surveyId: survey.id, workspaceId }, "Failed to validate single-use id");
}
if (!canonicalSingleUseId || canonicalSingleUseId !== responseInput.singleUseId) {
if (survey.singleUse.isEncrypted) {
const decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
if (decryptedSuId !== responseInput.singleUseId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
workspaceId,
});
}
} else if (responseInput.singleUseId !== suId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
workspaceId,
});
}
responseInput.singleUseId = canonicalSingleUseId;
}
if (survey.recaptcha?.enabled) {

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