Compare commits

..

128 Commits

Author SHA1 Message Date
Matti Nannt 2c93d40234 docs: add Spanish README summary 2026-04-01 11:05:22 +02:00
Matti Nannt 4f26278f16 docs: add German README summary (#7641) 2026-04-01 11:04:15 +02:00
Tiago b975e7fa2e feat: Make password reset links single-use and revocable (#7627) 2026-04-01 07:12:37 +00:00
Johannes 6c3052f9e4 fix: correct CSAT template option order for question 2 (#7636)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-01 07:11:27 +00:00
Dhruwang Jariwala 5bb8119ebf feat: split AI toggle into smart tools and data analysis settings (#7563) 2026-03-31 11:23:51 +00:00
Johannes 02411277d4 revert: remove fake-door workflows experiment (#7392) (#7631)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-03-31 10:47:33 +00:00
Dhruwang Jariwala 4cfb8c6d7b fix: resolve language code case mismatch in link survey rendering (#7624)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:34:20 +00:00
Anshuman Pandey e74a51a5ff fix: sync segment state after auto-save to prevent stale reference on publish (#7619) 2026-03-30 06:51:44 +00:00
Dhruwang Jariwala 29cc6a10fe fix: prevent auto-save from overwriting survey status during publish (#7618) 2026-03-30 06:34:20 +00:00
Bhagya Amarasinghe 01f765e969 fix: migrate auth sessions to database-backed storage (#7594) 2026-03-27 07:15:06 +00:00
Anshuman Pandey 9366960f18 feat: adds support for internal webhook urls (#7577) 2026-03-27 07:04:14 +00:00
IllimarR 697dc9cc99 feat: add Estonian language support for surveys (#7574)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-27 06:12:40 +00:00
Dhruwang Jariwala 83bc272ed2 fix: prevent duplicate hobby subscriptions from race condition (#7597)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 15:50:52 +00:00
Dhruwang Jariwala 59cc9c564e fix: duplicate org creation (#7593) 2026-03-26 05:52:09 +00:00
Dhruwang Jariwala 20dc147682 fix: scrolling behaviour to invalid questions (#7573) 2026-03-25 13:35:51 +00:00
cursor[bot] 2bb7a6f277 fix: prevent TypeError when checking for duplicate matrix labels (#7579)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-03-25 13:14:18 +00:00
Dhruwang Jariwala deb062dd03 fix: handle 404 race condition in Stripe webhook reconciliation (#7584) 2026-03-25 09:58:00 +00:00
Dhruwang Jariwala 474be86d33 fix: translations for option types (#7576) 2026-03-24 13:18:26 +00:00
Dhruwang Jariwala e7ca66ed77 fix: use TTC data for reliable survey impression counting (#7572) 2026-03-24 08:52:35 +00:00
Matti Nannt 2b49dbecd3 chore: add dev:setup script to generate .env and missing secrets (#7555)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 08:26:32 +00:00
Anshuman Pandey 6da4c6f352 fix: proper errors server side when resources are not found (#7571) 2026-03-24 07:52:37 +00:00
Aryan Ghugare 659b240fca feat: enhance welcome card to support video uploads and display #7491 (#7497)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 07:34:43 +00:00
Dhruwang Jariwala 19c0b1d14d fix: response table settings formatting (#7540)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 06:36:45 +00:00
Dhruwang Jariwala b4472f48e9 fix: (Duplicate) prevent multi-language survey buttons from falling back to English (#7559) 2026-03-24 05:45:47 +00:00
bharath kumar d197271771 fix(web): add <noscript> message for when JS is disabled (#7455) (#7459)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-23 12:35:29 +00:00
Dhruwang Jariwala 37f652c70e fix: prevent session expiry during active use (#7558) 2026-03-23 10:44:55 +00:00
Matti Nannt 645f0ab0d1 fix: resolve remaining dependabot alerts (#7561) 2026-03-23 09:59:01 +00:00
Johannes 389a7d9e7b feat: enhance segment activity summary and settings in segment modal (#7553)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-23 08:39:10 +00:00
Tiago c4cf468c7e fix: localize survey and app date rendering (#7473)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-23 07:23:07 +00:00
Johannes cbc3e923e4 fix: segment targeting "isNotIn" didnt work (#7550) 2026-03-23 05:22:19 +00:00
Tiago a96ba8b1e7 docs: clarify v2 contact API request body shapes (#1089) (#7552) 2026-03-20 16:23:06 +00:00
Johannes e830871361 docs: update docs re multi-lang (#7547) 2026-03-20 15:56:03 +00:00
Matti Nannt 998e5c0819 fix: resolve high severity dependabot alerts (#7551) 2026-03-20 15:55:15 +00:00
Balázs Úr 13a56b0237 fix: mark language selector tooltip as translatable (#7520) 2026-03-20 12:17:26 +00:00
Dhruwang Jariwala 0b5418a03a feat: searchable dropdown (#7530)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-20 12:15:48 +00:00
Anshuman Pandey 0d8a338965 fix: fixes welcome card logo removal bug (#7544) 2026-03-20 10:06:01 +00:00
Tiago d3250736a9 feat: add V3 surveys API (#7499) 2026-03-20 09:55:33 +00:00
Dhruwang Jariwala e6ee6a6b0d feat: choice rotation (#7512)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-20 06:54:05 +00:00
Dhruwang Jariwala c0b097f929 refactor: update CTA component styles and utility class groups (#7532) 2026-03-20 06:43:35 +00:00
Tiago 78d336f8c7 chore: Improve the webhook "Test Endpoint" feature (#7527) 2026-03-19 16:13:48 +01:00
Dhruwang Jariwala 95a7a265b9 feat: enhance survey display in webhook row with limited visibility (#7535) 2026-03-19 12:56:53 +00:00
Dhruwang Jariwala 136e59da68 fix: allow survey updation without followup access (#7528) 2026-03-19 11:42:14 +00:00
Anshuman Pandey eb0a87cf80 fix: fixes the loading skeleton on workspaces/tags page and some sentry improvements (#7533) 2026-03-19 11:09:52 +00:00
Anshuman Pandey 0dcb98ac29 fix: sdk init issues (#7516)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-19 11:04:12 +00:00
Balázs Úr 540f7aaae7 chore: change LINGO_API_KEY environment variable name (#7521) 2026-03-19 07:30:44 +00:00
Dhruwang Jariwala 2d4614a0bd chore: forward customer state to chatwoot (#7518) 2026-03-19 07:13:23 +00:00
Dhruwang Jariwala 633bf18204 fix: auto-expand multi-language card when toggle is enabled (#7504)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 12:18:35 +00:00
Balázs Úr 9a6cbd05b6 fix: mark various strings as translatable (#7338)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 11:30:38 +00:00
Johannes 94b0248075 fix: only allow URL in exact match URL (#7505)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 07:20:14 +00:00
Johannes 082de1042d feat: add validation for custom survey closed message heading (#7502) 2026-03-18 06:40:57 +00:00
Johannes 8c19587baa fix: ensure at least one filter is required for segments (#7503)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 06:39:58 +00:00
Anshuman Pandey 433750d3fe fix: removes pino pretty from edge runtime (#7510) 2026-03-18 06:32:55 +00:00
Johannes 61befd5ffd feat: add enterprise license features table (#7492)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 06:14:40 +00:00
Dhruwang Jariwala 1e7817fb69 fix: pre-strip style attributes before DOMPurify to prevent CSP violations (#7489)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-17 15:33:44 +00:00
Anshuman Pandey f250bc7e88 fix: fixes race between setUserId and trigger (#7498)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 08:57:07 +00:00
Santosh c7faa29437 fix: derive organizationId from resources in server actions to prevent cross-org IDOR (#7409)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 05:36:58 +00:00
Anshuman Pandey a51a006c26 fix: fixes data element i18n fixes (#7488) 2026-03-16 10:12:48 +00:00
Matti Nannt ce96cb0b89 feat: replace hosted stripe pricing table (#7486)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-16 10:11:40 +00:00
Matti Nannt fb265d9dba feat: add SAML telemetry reporting (#7461) 2026-03-16 09:41:33 +00:00
Matti Nannt e4c155b501 fix: defer hobby subscription creation (#7484) 2026-03-15 14:13:53 +00:00
Johannes 2dc5c50f4d feat: implement trial days remaining alert in billing components (#7474) 2026-03-13 16:38:43 +01:00
Anshuman Pandey bddcec0466 fix: adds monkey patching for replaceState (#7475) 2026-03-13 13:40:20 +00:00
Dhruwang Jariwala 92677e1ec0 fix: respect overwriteThemeStyling in link survey metadata (#7466)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-13 13:07:54 +00:00
Anshuman Pandey b12228e305 fix: fixes button url fixes in survey editor (#7472) 2026-03-13 13:07:41 +00:00
Dhruwang Jariwala 91be2af30b fix: add missing Stripe billing setup for setup route org creation (#7470)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:18:01 +01:00
Anshuman Pandey 84c668be86 fix: fixes contact links api gating issue (#7468) 2026-03-13 11:09:53 +00:00
Dhruwang Jariwala 4015c76f2b fix: use logical CSS direction classes for RTL matrix question (#7463)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:06:41 +00:00
Dhruwang Jariwala a7b2ade4a9 fix: remove follow-ups from trial features and gate trial page for subscribers (#7465)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:00:23 +00:00
Dhruwang Jariwala 75f44952c7 fix: clear validation settings when disabling open text validation (#7464)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 09:39:42 +00:00
Bhagya Amarasinghe 0df5e26381 fix: handle license 403 as instance mismatch (#7458)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-12 10:46:44 +00:00
Matti Nannt 89bb3bcd84 chore: apply NCU minor upgrades fixups (#7460) 2026-03-12 10:44:18 +00:00
Harsh Bhat 30fdb72c09 feat: add PostHog analytics (#7454)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-12 09:53:14 +01:00
Matti Nannt cb58cf5825 fix: restrict selected entitlements during trial (#7456)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-12 08:10:23 +00:00
Johannes 99bd2ba256 feat: add reverse trial functionality (#7435)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-03-11 14:47:48 +00:00
Anshuman Pandey 9df423073f fix: zlib CVE (#7444) 2026-03-11 11:10:29 +00:00
Johannes 3e3c696972 feat: add trigger after time passed (#7452)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-11 10:12:31 +00:00
Anshuman Pandey cb41e2d344 fix: sets apps/web TS strict check to true (#7451) 2026-03-11 10:14:37 +01:00
Matti Nannt 1e19cca7d9 feat: implement cloud stripe billing sync and pricing revamp (#7309)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 16:04:23 +00:00
Johannes fa882dd4cc fix: improve survey validation error handling in SurveyMenuBar component (#7447) 2026-03-10 10:23:05 +00:00
Matti Nannt 0b82c6de77 feat: move multi-language surveys and workspace languages to AGPL (#7426)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-10 09:28:01 +00:00
Balázs Úr a944d7626e chore: use Unicode punctuation, remove contractions, make wording consistent (#7355)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 07:06:30 +00:00
Balázs Úr d1b12dc228 fix: mark strings as translatable in survey editor (#7369)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 06:14:29 +00:00
Bhagya Amarasinghe 9f7d6038b1 docs: add CDN guidance for self-hosting (#7446)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 06:12:36 +00:00
Balázs Úr 1da92addd2 fix: Hungarian translations (#7434)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-09 12:31:24 +00:00
Dhruwang Jariwala 1e4aa5f54b fix: strip inline styles preserve target attr (#7441)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:09:51 +00:00
Anshuman Pandey 96f173c3b1 fix: overrides packages for CVE fixes (#7442) 2026-03-09 09:55:02 +00:00
Harsh Bhat 9c9e55fba6 docs: add keycloack docs (#7440) 2026-03-09 08:38:00 +00:00
Johannes 42541f86fd feat(navigation): add workflows section to main navigation and update… (#7392) 2026-03-08 18:13:38 +00:00
Matti Nannt 0ba469a73d fix: pin fast-xml-parser to 5.3.5 (#7436) 2026-03-06 20:20:34 +01:00
Matti Nannt afa192e5b9 chore: upgrade deps and Zod v4 migration (#7425)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-06 14:41:28 +01:00
Bhagya Amarasinghe 4860a9a5cf fix: helm template duplicate label key in migration-job (#7431)
Co-authored-by: Rob <178471500+rob-htl@users.noreply.github.com>
2026-03-06 11:48:07 +00:00
Chowdhury Tafsir Ahmed Siddiki af02ce9ea6 fix: display native language names in profile language selector (#7349)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 10:18:52 +00:00
Bhagya Amarasinghe fc1c91896a fix: add server-side SSRF validation for webhook URLs (#7414)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-06 07:36:49 +00:00
Balázs Úr f5c7dbdc71 fix: mark duplicated survey name as translatable (#7379)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 06:37:05 +00:00
Balázs Úr b88ea5cc66 fix: use proper plural forms (#7322)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 06:30:27 +00:00
bharath kumar f31085a9e7 fix(i18n): resolve duplicate Hungarian translations causing Career Development Survey creation to fail (#7410)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 05:39:05 +00:00
Dhruwang Jariwala 2ab0441404 fix: z-index for multi select question with dropdwon display type (#7420) 2026-03-06 04:56:39 +00:00
Dhruwang Jariwala 299ae81b21 chore: mls tweaks (#7416) 2026-03-05 14:55:45 +00:00
Bhagya Amarasinghe f73f13f16c perf: fix Prisma connection pool saturation from unbounded Promise.all fan-outs (#7404) 2026-03-05 14:35:40 +00:00
Matti Nannt e9bcbf6e4c fix: patch @isaacs/brace-expansion to 5.0.1 (#7424) 2026-03-05 13:35:48 +00:00
Matti Nannt 32eda35a71 chore: clean up stale turbo task config (#7423) 2026-03-05 11:49:24 +00:00
Dhruwang Jariwala 84999cddfd feat: danish support to surveys package (#7415) 2026-03-05 11:05:40 +00:00
Matti Nannt f0a0cf531a chore: clean up unused npm dependencies (#7417)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-05 10:48:13 +00:00
Matti Nannt f3e02fa466 chore: optimize monorepo build performance (#7419) 2026-03-05 10:18:54 +00:00
Dhruwang Jariwala f0a93ae092 fix: add Tailwind v3 config for Prettier in apps/web and packages/email (#7421) 2026-03-05 10:05:05 +00:00
Matti Nannt 1c922dfe2c chore: remove legacy post-checkout hook (#7418) 2026-03-05 08:14:19 +00:00
Bhagya Amarasinghe 33010fb6f5 fix: auto-save creates duplicate follow ups (#7413)
Co-authored-by: gulshank0 <gulshanbahadur002@gmail.com>
2026-03-05 00:44:29 +00:00
Matti Nannt d5fdacadd7 chore: update dependencies and fix build/lint/test regressions (#7403) 2026-03-03 17:03:03 +00:00
bharath kumar d939263472 fix(sdk): add userId length limit to mitigate DoS attack risk (#7378)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-03-03 10:10:01 +01:00
Dhruwang Jariwala e4aa66b067 fix: removed legacy response note traces (#7396) 2026-03-02 12:58:37 +00:00
Dhruwang Jariwala ffcc101ed9 chore: make productionBrowserSourceMaps conditional to decrease build time (#7400) 2026-03-02 09:49:00 +00:00
Balázs Úr 2740cd16b9 fix: delete confirmation dialog title translation (#7358)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-02 07:06:14 +00:00
Dhruwang Jariwala 7eb94f0bd5 fix: theme styling preview, option border color, and enable custom styling behavior (#7387)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-02 06:17:52 +00:00
Johannes 6dd2e707fe feat: display Formbricks version alongside organization ID in settings (#7363) 2026-03-02 05:54:23 +00:00
Matti Nannt 58d5de7d45 fix: resolve Dependabot Next.js deserialization alert (#7393) 2026-02-27 22:18:38 +01:00
Dhruwang Jariwala 7c3fa8b5ea fix: restore bullet points in survey preview and public survey (#7356) (#7360)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-27 18:24:15 +00:00
Harsh Bhat 2601169877 docs: add advanced CSS variable updates (#7389)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-02-27 17:19:22 +00:00
bharath kumar aecf85815a fix(js-core): use closest() fallback for nested click target matching (#7327)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-27 06:24:58 +00:00
Bhagya Amarasinghe c6ebaea989 fix: set success_action_status on S3 presigned POST to fix CORS on Ceph-based providers (#7362) 2026-02-26 10:26:49 +00:00
Bhagya Amarasinghe 68c1422733 fix: copy database package.json to Docker runner stage (#7371) 2026-02-26 10:25:28 +00:00
Dhruwang Jariwala 6942502baf fix: slack missing redirect uri (#7372) 2026-02-26 10:01:25 +00:00
Theodór Tómas a4bd217761 chore: update to zod 3.25.76 (#7366) 2026-02-26 05:17:20 +00:00
Bhagya Amarasinghe fee770358c perf(contacts): build segment WHERE clauses sequentially to prevent pool saturation (#7354)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-25 15:25:32 +00:00
Dhruwang Jariwala 44f8f80cac docs: clarify startAt is block-based, not question-based (#1404) (#7352)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 13:19:30 +00:00
Chowdhury Tafsir Ahmed Siddiki 858a7f7aa9 fix: replace toSorted in breadcrumb switchers for compatibility (#7325)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-25 06:29:31 +00:00
Gulshan ac40b90e81 fix: made "Filter" string translatable (#7301)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-25 06:28:51 +00:00
Balázs Úr aa21b4e442 fix: made Contact's page titles and table headers translatable (#7313)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-24 14:07:05 +00:00
Dhruwang Jariwala fa72296de5 fix: error state for multi select question (#7335) 2026-02-24 13:34:48 +00:00
1117 changed files with 39683 additions and 23459 deletions
+13 -1
View File
@@ -94,6 +94,12 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too. # Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1 PASSWORD_RESET_DISABLED=1
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
# DEBUG_SHOW_RESET_LINK=1
# Email login. Disable the ability for users to login with email. # Email login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1 # EMAIL_AUTH_DISABLED=1
@@ -150,6 +156,7 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET= NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables # Stripe Billing Variables
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
@@ -184,6 +191,11 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app # Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1 # RATE_LIMITING_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics) # OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
@@ -230,4 +242,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation # Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here LINGO_API_KEY=your_api_key_here
@@ -285,12 +285,14 @@ runs:
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }} encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }} redis_url=${{ env.DUMMY_REDIS_URL }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }} sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
posthog_key=${{ env.POSTHOG_KEY }}
env: env:
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }} DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }} DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }} DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }} DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
- name: Sign GHCR image (GHCR only) - name: Sign GHCR image (GHCR only)
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }} if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
+1
View File
@@ -92,3 +92,4 @@ jobs:
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }} DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }} DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
+1 -1
View File
@@ -45,7 +45,7 @@ yarn-error.log*
.direnv .direnv
# Playwright # Playwright
/test-results/ **/test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
-2
View File
@@ -1,2 +0,0 @@
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
prettier --write ./branch.json
+8
View File
@@ -52,6 +52,14 @@ We are using SonarQube to identify code smells and security hotspots.
- Translations are in `apps/web/locales/`. Default is `en-US.json`. - Translations are in `apps/web/locales/`. Default is `en-US.json`.
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys. - Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
## Date and Time Rendering
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
## Database & Prisma Performance ## Database & Prisma Performance
- Multi-tenancy: All data must be scoped by Organization or Environment. - Multi-tenancy: All data must be scoped by Organization or Environment.
+22
View File
@@ -247,4 +247,26 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come. The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
<a id="readme-de"></a>
## Deutsch
Formbricks ist eine freie, quelloffene und datenschutzorientierte Plattform für Surveys und Experience Management. Mit In-App-, Website-, Link- und E-Mail-Umfragen sammelt ihr Feedback entlang der gesamten User Journey.
- Website & Cloud: [formbricks.com](https://formbricks.com/) und [Cloud starten](https://app.formbricks.com/auth/signup)
- Self-Hosting: [Deployment-Dokumentation](https://formbricks.com/docs/self-hosting/deployment)
- Beitrag & Community: [Beitragen](https://formbricks.com/docs/developer-docs/contributing/get-started), [GitHub Discussions](https://github.com/formbricks/formbricks/discussions) und [Issues](https://github.com/formbricks/formbricks/issues)
- Sicherheit & Lizenz: [`SECURITY.md`](./SECURITY.md) und [AGPLv3](https://github.com/formbricks/formbricks/blob/main/LICENSE)
<a id="readme-es"></a>
## Español
Formbricks es una plataforma libre, de código abierto y centrada en la privacidad para encuestas y experience management. Permite recoger feedback durante todo el recorrido del usuario con encuestas dentro de la app, en sitios web, por enlace y por correo electrónico.
- Sitio web y Cloud: [formbricks.com](https://formbricks.com/) y [empezar en Cloud](https://app.formbricks.com/auth/signup)
- Self-Hosting: [documentación de despliegue](https://formbricks.com/docs/self-hosting/deployment)
- Contribución y comunidad: [guía para contribuir](https://formbricks.com/docs/developer-docs/contributing/get-started), [GitHub Discussions](https://github.com/formbricks/formbricks/discussions) e [Issues](https://github.com/formbricks/formbricks/issues)
- Seguridad y licencia: [`SECURITY.md`](./SECURITY.md) y [AGPLv3](https://github.com/formbricks/formbricks/blob/main/LICENSE)
<p align="right"><a href="#top">🔼 Back to top</a></p> <p align="right"><a href="#top">🔼 Back to top</a></p>
+12 -17
View File
@@ -10,25 +10,20 @@
"build-storybook": "storybook build", "build-storybook": "storybook build",
"clean": "rimraf .turbo node_modules dist storybook-static" "clean": "rimraf .turbo node_modules dist storybook-static"
}, },
"dependencies": {
"@formbricks/survey-ui": "workspace:*"
},
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^5.0.0", "@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.1.11", "@storybook/addon-a11y": "10.2.17",
"@storybook/addon-links": "10.1.11", "@storybook/addon-links": "10.2.17",
"@storybook/addon-onboarding": "10.1.11", "@storybook/addon-onboarding": "10.2.17",
"@storybook/react-vite": "10.1.11", "@storybook/react-vite": "10.2.17",
"@typescript-eslint/eslint-plugin": "8.53.0", "@typescript-eslint/eslint-plugin": "8.57.0",
"@tailwindcss/vite": "4.1.18", "@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.53.0", "@typescript-eslint/parser": "8.57.0",
"@vitejs/plugin-react": "5.1.2", "@vitejs/plugin-react": "5.1.4",
"esbuild": "0.25.12",
"eslint-plugin-react-refresh": "0.4.26", "eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.1.11", "eslint-plugin-storybook": "10.2.17",
"prop-types": "15.8.1", "storybook": "10.2.17",
"storybook": "10.1.11",
"vite": "7.3.1", "vite": "7.3.1",
"@storybook/addon-docs": "10.1.11" "@storybook/addon-docs": "10.2.17"
} }
} }
+6
View File
@@ -0,0 +1,6 @@
const baseConfig = require("../../.prettierrc.js");
module.exports = {
...baseConfig,
tailwindConfig: "./tailwind.config.js",
};
+14 -6
View File
@@ -18,7 +18,7 @@ FROM node:24-alpine3.23 AS base
FROM base AS installer FROM base AS installer
# Enable corepack and prepare pnpm # Enable corepack and prepare pnpm
RUN npm install --ignore-scripts -g corepack@latest RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable RUN corepack enable
RUN corepack prepare pnpm@10.28.2 --activate RUN corepack prepare pnpm@10.28.2 --activate
@@ -67,6 +67,7 @@ RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \ --mount=type=secret,id=encryption_key \
--mount=type=secret,id=redis_url \ --mount=type=secret,id=redis_url \
--mount=type=secret,id=sentry_auth_token \ --mount=type=secret,id=sentry_auth_token \
--mount=type=secret,id=posthog_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web... /tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# #
@@ -74,9 +75,10 @@ RUN --mount=type=secret,id=database_url \
# #
FROM base AS runner FROM base AS runner
# Update npm to latest, then create user # Upgrade Alpine system packages to pick up security patches, update npm to latest, then create user
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime # Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
RUN npm install --ignore-scripts -g npm@latest \ RUN apk update && apk upgrade --no-cache \
&& npm install --ignore-scripts -g npm@latest \
&& addgroup -S nextjs \ && addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs && adduser -S -u 1001 -G nextjs nextjs
@@ -101,6 +103,9 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
# Create packages/database directory structure with proper ownership for runtime migrations # Create packages/database directory structure with proper ownership for runtime migrations
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
@@ -117,8 +122,11 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2 COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2 RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid # Runtime migrations import uuid v7 from the database package, so copy the
RUN chmod -R 755 ./node_modules/uuid # database package's resolved install instead of the repo-root hoisted version.
COPY --from=installer /app/packages/database/node_modules/uuid ./node_modules/uuid
RUN chmod -R 755 ./node_modules/uuid \
&& node --input-type=module -e "import('uuid').then((module) => { if (typeof module.v7 !== 'function') throw new Error('uuid v7 missing in runtime image'); })"
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN chmod -R 755 ./node_modules/@noble/hashes RUN chmod -R 755 ./node_modules/@noble/hashes
@@ -161,4 +169,4 @@ RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
VOLUME /home/nextjs/apps/web/uploads/ VOLUME /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"] CMD ["/home/nextjs/start.sh"]
@@ -69,7 +69,7 @@ export const ConnectWithFormbricks = ({
) : ( ) : (
<div className="flex animate-pulse flex-col items-center space-y-4"> <div className="flex animate-pulse flex-col items-center space-y-4">
<span className="relative flex h-10 w-10"> <span className="relative flex h-10 w-10">
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span> <span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span> <span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span> </span>
<p className="pt-4 text-sm font-medium text-slate-600"> <p className="pt-4 text-sm font-medium text-slate-600">
@@ -1,5 +1,6 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks"; import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
@@ -20,12 +21,12 @@ const Page = async (props: ConnectPageProps) => {
const environment = await getEnvironment(params.environmentId); const environment = await getEnvironment(params.environmentId);
if (!environment) { if (!environment) {
throw new Error(t("common.environment_not_found")); throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
} }
const project = await getProjectByEnvironmentId(environment.id); const project = await getProjectByEnvironmentId(environment.id);
if (!project) { if (!project) {
throw new Error(t("common.workspace_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
const channel = project.config.channel || null; const channel = project.config.channel || null;
@@ -46,7 +47,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel} channel={channel}
/> />
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}`}> <Link href={`/environments/${environment.id}`}>
@@ -4,7 +4,10 @@ import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const OnboardingLayout = async (props) => { const OnboardingLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -2,6 +2,7 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project"; import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { replacePresetPlaceholders } from "./utils"; import { replacePresetPlaceholders } from "./utils";
@@ -39,13 +40,13 @@ const mockTemplate: TXMTemplate = {
elements: [ elements: [
{ {
id: "q1", id: "q1",
type: "openText" as const, type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const, inputType: "text" as const,
headline: { default: "$[projectName] Question" }, headline: { default: "$[projectName] Question" },
subheader: { default: "" }, subheader: { default: "" },
required: false, required: false,
placeholder: { default: "" }, placeholder: { default: "" },
charLimit: 1000, charLimit: { enabled: true, max: 1000 },
}, },
], ],
}, },
@@ -14,7 +14,7 @@ describe("xm-templates", () => {
}); });
test("getXMSurveyDefault returns default survey template", () => { test("getXMSurveyDefault returns default survey template", () => {
const tMock = vi.fn((key) => key) as TFunction; const tMock = vi.fn((key: string) => key) as unknown as TFunction;
const result = getXMSurveyDefault(tMock); const result = getXMSurveyDefault(tMock);
expect(result).toEqual({ expect(result).toEqual({
@@ -29,7 +29,7 @@ describe("xm-templates", () => {
}); });
test("getXMTemplates returns all templates", () => { test("getXMTemplates returns all templates", () => {
const tMock = vi.fn((key) => key) as TFunction; const tMock = vi.fn((key: string) => key) as unknown as TFunction;
const result = getXMTemplates(tMock); const result = getXMTemplates(tMock);
expect(result).toHaveLength(6); expect(result).toHaveLength(6);
@@ -44,7 +44,7 @@ describe("xm-templates", () => {
test("getXMTemplates handles errors gracefully", async () => { test("getXMTemplates handles errors gracefully", async () => {
const tMock = vi.fn(() => { const tMock = vi.fn(() => {
throw new Error("Test error"); throw new Error("Test error");
}) as TFunction; }) as unknown as TFunction;
const result = getXMTemplates(tMock); const result = getXMTemplates(tMock);
@@ -1,6 +1,7 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList"; import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service"; import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
@@ -23,22 +24,22 @@ const Page = async (props: XMTemplatePageProps) => {
const environment = await getEnvironment(params.environmentId); const environment = await getEnvironment(params.environmentId);
const t = await getTranslate(); const t = await getTranslate();
if (!session) { if (!session) {
throw new Error(t("common.session_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
if (!environment) { if (!environment) {
throw new Error(t("common.environment_not_found")); throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
} }
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id); const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id); const project = await getProjectByEnvironmentId(environment.id);
if (!project) { if (!project) {
throw new Error(t("common.workspace_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
const projects = await getUserProjects(session.user.id, organizationId); const projects = await getUserProjects(session.user.id, organizationId);
@@ -49,7 +50,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} /> <XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && ( {projects.length >= 2 && (
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}/surveys`}> <Link href={`/environments/${environment.id}/surveys`}>
@@ -19,8 +19,8 @@ describe("getTeamsByOrganizationId", () => {
test("returns mapped teams", async () => { test("returns mapped teams", async () => {
const mockTeams = [ const mockTeams = [
{ id: "t1", name: "Team 1" }, { id: "t1", name: "Team 1", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
{ id: "t2", name: "Team 2" }, { id: "t2", name: "Team 2", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
]; ];
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams); vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1"); const result = await getTeamsByOrganizationId("org1");
@@ -22,12 +22,10 @@ export const getTeamsByOrganizationId = reactCache(
}, },
}); });
const projectTeams = teams.map((team) => ({ return teams.map((team: TOrganizationTeam) => ({
id: team.id, id: team.id,
name: team.name, name: team.name,
})); }));
return projectTeams;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
return ( return (
<aside <aside
className={cn( className={cn(
"w-sidebar-collapsed z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100" "z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}> )}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} /> <Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
@@ -5,7 +5,10 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props) => { const LandingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -10,7 +10,7 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
const Page = async (props) => { const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors"; import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { canUserAccessOrganization } from "@/lib/organization/auth"; import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
@@ -8,7 +8,10 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props) => { const ProjectOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -22,7 +25,7 @@ const ProjectOnboardingLayout = async (props) => {
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId); const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
@@ -33,7 +36,7 @@ const ProjectOnboardingLayout = async (props) => {
const organization = await getOrganization(params.organizationId); const organization = await getOrganization(params.organizationId);
if (!organization) { if (!organization) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
} }
return ( return (
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
@@ -8,7 +9,10 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props) => { const OnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -25,11 +29,13 @@ const OnboardingLayout = async (props) => {
const organization = await getOrganization(params.organizationId); const organization = await getOrganization(params.organizationId);
if (!organization) { if (!organization) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
} }
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits); const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id); getOrganizationProjectsLimit(organization.id),
getOrganizationProjectsCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) { if (organizationProjectsCount >= organizationProjectsLimit) {
return redirect(`/`); return redirect(`/`);
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -0,0 +1,22 @@
import { getTranslate } from "@/lingodotdev/server";
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
import { Header } from "@/modules/ui/components/header";
interface SelectPlanOnboardingProps {
organizationId: string;
}
export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboardingProps) => {
const t = await getTranslate();
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
<Header
title={t("environments.settings.billing.select_plan_header_title")}
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
/>
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
</div>
);
};
@@ -0,0 +1,42 @@
import { redirect } from "next/navigation";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
interface PlanPageProps {
params: Promise<{
organizationId: string;
}>;
}
const Page = async (props: PlanPageProps) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
}
const { session } = await getOrganizationAuth(params.organizationId);
if (!session?.user) {
return redirect(`/auth/login`);
}
// Users with an existing paid/trial subscription should not be shown the trial page.
// Redirect them directly to the next onboarding step.
const billing = await getOrganizationBillingWithReadThroughSync(params.organizationId);
const currentPlan = billing?.stripe?.plan;
const hasExistingSubscription = currentPlan !== undefined && PAID_PLANS.has(currentPlan);
if (hasExistingSubscription) {
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
}
return <SelectPlanOnboarding organizationId={params.organizationId} />;
};
export default Page;
@@ -228,7 +228,7 @@ export const ProjectSettings = ({
</FormProvider> </FormProvider>
</div> </div>
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow"> <div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow">
{logoUrl && ( {logoUrl && (
<Image <Image
src={logoUrl} src={logoUrl}
@@ -239,18 +239,16 @@ export const ProjectSettings = ({
/> />
)} )}
<p className="text-sm text-slate-400">{t("common.preview")}</p> <p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4"> <SurveyInline
<SurveyInline appUrl={publicDomain}
appUrl={publicDomain} isPreviewMode={true}
isPreviewMode={true} survey={previewSurvey(projectName || t("common.my_product"), t)}
survey={previewSurvey(projectName || "my Product", t)} styling={previewStyling}
styling={previewStyling} isBrandingEnabled={false}
isBrandingEnabled={false} languageCode="default"
languageCode="default" onFileUpload={async (file) => file.name}
onFileUpload={async (file) => file.name} autoFocus={false}
autoFocus={false} />
/>
</div>
</div> </div>
<CreateTeamModal <CreateTeamModal
open={createTeamModalOpen} open={createTeamModalOpen}
@@ -1,6 +1,7 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project"; import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings"; import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
@@ -42,10 +43,10 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId); const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan); const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!organizationTeams) { if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found")); throw new ResourceNotFoundError(t("common.team"), null);
} }
const publicDomain = getPublicDomain(); const publicDomain = getPublicDomain();
@@ -69,7 +70,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/> />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -16,7 +16,7 @@ interface OnboardingOptionsContainerProps {
} }
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => { export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
const getOptionCard = (option) => { const getOptionCard = (option: OnboardingOptionsContainerProps["options"][number]) => {
const Icon = option.icon; const Icon = option.icon;
return ( return (
<OptionCard <OptionCard
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
export const ZOrganizationTeam = z.object({ export const ZOrganizationTeam = z.object({
id: z.string().cuid2(), id: z.cuid2(),
name: z.string(), name: z.string(),
}); });
@@ -1,8 +1,12 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
const SurveyEditorEnvironmentLayout = async (props) => { const SurveyEditorEnvironmentLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -14,13 +18,13 @@ const SurveyEditorEnvironmentLayout = async (props) => {
} }
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
const environment = await getEnvironment(params.environmentId); const environment = await getEnvironment(params.environmentId);
if (!environment) { if (!environment) {
throw new Error(t("common.environment_not_found")); throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
} }
return ( return (
@@ -6,15 +6,26 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti"; import { Confetti } from "@/modules/ui/components/confetti";
interface ConfirmationPageProps { const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
environmentId: string;
}
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => { export const ConfirmationPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false); const [showConfetti, setShowConfetti] = useState(false);
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setShowConfetti(true); setShowConfetti(true);
if (globalThis.window === undefined) {
return;
}
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
);
if (storedEnvironmentId) {
setResolvedEnvironmentId(storedEnvironmentId);
}
}, []); }, []);
return ( return (
@@ -30,7 +41,12 @@ export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
</p> </p>
</div> </div>
<Button asChild className="w-full justify-center"> <Button asChild className="w-full justify-center">
<Link href={`/environments/${environmentId}/settings/billing`}> <Link
href={
resolvedEnvironmentId
? `/environments/${resolvedEnvironmentId}/settings/billing`
: "/environments"
}>
{t("billing_confirmation.back_to_billing_overview")} {t("billing_confirmation.back_to_billing_overview")}
</Link> </Link>
</Button> </Button>
@@ -3,13 +3,10 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const Page = async (props) => { const Page = async () => {
const searchParams = await props.searchParams;
const { environmentId } = searchParams;
return ( return (
<PageContentWrapper> <PageContentWrapper>
<ConfirmationPage environmentId={environmentId?.toString()} /> <ConfirmationPage />
</PageContentWrapper> </PageContentWrapper>
); );
}; };
@@ -2,7 +2,11 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors"; import {
AuthorizationError,
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project"; import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
@@ -10,7 +14,6 @@ import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service"; import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { import {
getAccessControlPermission, getAccessControlPermission,
@@ -25,67 +28,63 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput, data: ZProjectUpdateInput,
}); });
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action( export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging( withAuditLogging("created", "project", async ({ ctx, parsedInput }) => {
"created", const { user } = ctx;
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId; const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: user.id, userId: user.id,
organizationId: parsedInput.organizationId, organizationId: parsedInput.organizationId,
access: [ access: [
{ {
data: parsedInput.data, data: parsedInput.data,
schema: ZProjectUpdateInput, schema: ZProjectUpdateInput,
type: "organization", type: "organization",
roles: ["owner", "manager"], roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
}, },
}; ],
});
await updateUser(user.id, { const organization = await getOrganization(organizationId);
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId; if (!organization) {
ctx.auditLoggingCtx.projectId = project.id; throw new ResourceNotFoundError("Organization", organizationId);
ctx.auditLoggingCtx.newObject = project;
return project;
} }
)
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
})
); );
const ZGetOrganizationsForSwitcherAction = z.object({ const ZGetOrganizationsForSwitcherAction = z.object({
@@ -97,7 +96,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
* Called on-demand when user opens the organization switcher. * Called on-demand when user opens the organization switcher.
*/ */
export const getOrganizationsForSwitcherAction = authenticatedActionClient export const getOrganizationsForSwitcherAction = authenticatedActionClient
.schema(ZGetOrganizationsForSwitcherAction) .inputSchema(ZGetOrganizationsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -122,7 +121,7 @@ const ZGetProjectsForSwitcherAction = z.object({
* Called on-demand when user opens the project switcher. * Called on-demand when user opens the project switcher.
*/ */
export const getProjectsForSwitcherAction = authenticatedActionClient export const getProjectsForSwitcherAction = authenticatedActionClient
.schema(ZGetProjectsForSwitcherAction) .inputSchema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation"; import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar"; import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
@@ -29,7 +30,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isAccessControlAllowed, isAccessControlAllowed,
projectPermission, projectPermission,
license, license,
peopleCount,
responseCount, responseCount,
} = layoutData; } = layoutData;
@@ -38,12 +38,12 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
const { features, lastChecked, isPendingDowngrade, active, status } = license; const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false; const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits); const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const isOwnerOrManager = isOwner || isManager; const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members // Validate that project permission exists for members
if (isMember && !projectPermission) { if (isMember && !projectPermission) {
throw new Error(t("common.workspace_permission_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
return ( return (
@@ -52,7 +52,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<LimitsReachedBanner <LimitsReachedBanner
organization={organization} organization={organization}
environmentId={environment.id} environmentId={environment.id}
peopleCount={peopleCount}
responseCount={responseCount} responseCount={responseCount}
/> />
)} )}
@@ -27,6 +27,7 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions"; import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars"; import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -118,7 +119,7 @@ export const MainNavigation = ({
name: t("common.configuration"), name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`, href: `/environments/${environment.id}/workspace/general`,
icon: Cog, icon: Cog,
isActive: pathname?.includes("/project"), isActive: pathname?.includes("/workspace"),
}, },
], ],
[t, environment.id, pathname] [t, environment.id, pathname]
@@ -159,6 +160,20 @@ export const MainNavigation = ({
if (isOwnerOrManager) loadReleases(); if (isOwnerOrManager) loadReleases();
}, [isOwnerOrManager]); }, [isOwnerOrManager]);
const trialDaysRemaining = useMemo(() => {
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
const trialEnd = organization.billing.stripe.trialEnd;
if (!trialEnd) return null;
const ts = new Date(trialEnd).getTime();
if (!Number.isFinite(ts)) return null;
const msPerDay = 86_400_000;
return Math.ceil((ts - Date.now()) / msPerDay);
}, [
isFormbricksCloud,
organization.billing?.stripe?.subscriptionStatus,
organization.billing?.stripe?.trialEnd,
]);
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`; const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
return ( return (
@@ -188,7 +203,7 @@ export const MainNavigation = ({
size="icon" size="icon"
onClick={toggleSidebar} onClick={toggleSidebar}
className={cn( className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none" "rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}> )}>
{isCollapsed ? ( {isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} /> <PanelLeftOpenIcon strokeWidth={1.5} />
@@ -233,6 +248,13 @@ export const MainNavigation = ({
</Link> </Link>
)} )}
{/* Trial Days Remaining */}
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
)}
{/* User Switch */} {/* User Switch */}
<div className="flex items-center"> <div className="flex items-center">
<DropdownMenu> <DropdownMenu>
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon /> <currentStatus.icon />
</div> </div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p> <p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p> <p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && ( {status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}> <Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon /> <RotateCcwIcon />
@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => { getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) { if (result?.data) {
// Sort organizations by name // Sort organizations by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name)); const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted); setOrganizations(sorted);
} else { } else {
// Handle server errors or validation errors // Handle server errors or validation errors
@@ -82,7 +82,7 @@ export const ProjectBreadcrumb = ({
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => { getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) { if (result?.data) {
// Sort projects by name // Sort projects by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name)); const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted); setProjects(sorted);
} else { } else {
// Handle server errors or validation errors // Handle server errors or validation errors
@@ -4,7 +4,7 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
const EnvironmentPage = async (props) => { const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params; const params = await props.params;
const { session, organization } = await getEnvironmentAuth(params.environmentId); const { session, organization } = await getEnvironmentAuth(params.environmentId);
@@ -1,10 +1,14 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const AccountSettingsLayout = async (props) => { const AccountSettingsLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -17,15 +21,15 @@ const AccountSettingsLayout = async (props) => {
]); ]);
if (!organization) { if (!organization) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), null);
} }
if (!project) { if (!project) {
throw new Error(t("common.workspace_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
if (!session) { if (!session) {
throw new Error(t("common.session_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
return <>{children}</>; return <>{children}</>;
@@ -4,7 +4,6 @@ import { z } from "zod";
import { ZUserNotificationSettings } from "@formbricks/types/user"; import { ZUserNotificationSettings } from "@formbricks/types/user";
import { getUser, updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const ZUpdateNotificationSettingsAction = z.object({ const ZUpdateNotificationSettingsAction = z.object({
@@ -12,26 +11,16 @@ const ZUpdateNotificationSettingsAction = z.object({
}); });
export const updateNotificationSettingsAction = authenticatedActionClient export const updateNotificationSettingsAction = authenticatedActionClient
.schema(ZUpdateNotificationSettingsAction) .inputSchema(ZUpdateNotificationSettingsAction)
.action( .action(
withAuditLogging( withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
"updated", const oldObject = await getUser(ctx.user.id);
"user", const result = await updateUser(ctx.user.id, {
async ({ notificationSettings: parsedInput.notificationSettings,
ctx, });
parsedInput, ctx.auditLoggingCtx.userId = ctx.user.id;
}: { ctx.auditLoggingCtx.oldObject = oldObject;
ctx: AuthenticatedActionClientCtx; ctx.auditLoggingCtx.newObject = result;
parsedInput: Record<string, any>; return result;
}) => { })
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
); );
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user"; import { TUserNotificationSettings } from "@formbricks/types/user";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
@@ -16,8 +17,8 @@ const setCompleteNotificationSettings = (
notificationSettings: TUserNotificationSettings, notificationSettings: TUserNotificationSettings,
memberships: Membership[] memberships: Membership[]
): TUserNotificationSettings => { ): TUserNotificationSettings => {
const newNotificationSettings = { const newNotificationSettings: TUserNotificationSettings = {
alert: {}, alert: {} as Record<string, boolean>,
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [], unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
}; };
for (const membership of memberships) { for (const membership of memberships) {
@@ -26,7 +27,8 @@ const setCompleteNotificationSettings = (
for (const environment of project.environments) { for (const environment of project.environments) {
for (const survey of environment.surveys) { for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] = newNotificationSettings.alert[survey.id] =
notificationSettings[survey.id]?.responseFinished || (notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
?.responseFinished ||
(notificationSettings.alert && notificationSettings.alert[survey.id]) || (notificationSettings.alert && notificationSettings.alert[survey.id]) ||
false; // check for legacy notification settings w/o "alerts" key false; // check for legacy notification settings w/o "alerts" key
} }
@@ -136,24 +138,27 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
return memberships; return memberships;
}; };
const Page = async (props) => { const Page = async (props: {
params: Promise<{ environmentId: string }>;
searchParams: Promise<Record<string, string>>;
}) => {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session) { if (!session) {
throw new Error(t("common.session_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
const autoDisableNotificationType = searchParams["type"]; const autoDisableNotificationType = searchParams["type"];
const autoDisableNotificationElementId = searchParams["elementId"]; const autoDisableNotificationElementId = searchParams["elementId"];
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]); const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
if (!memberships) { if (!memberships) {
throw new Error(t("common.membership_not_found")); throw new ResourceNotFoundError(t("common.membership"), null);
} }
if (user?.notificationSettings) { if (user?.notificationSettings) {
@@ -10,17 +10,18 @@ import {
getIsEmailUnique, getIsEmailUnique,
verifyUserPassword, verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user"; } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { getUser, updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email"; import { sendVerificationNewEmail } from "@/modules/email";
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput { function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
return { return {
...(parsedInput.name && { name: parsedInput.name }), ...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }), ...(parsedInput.locale && { locale: parsedInput.locale }),
@@ -63,50 +64,40 @@ async function handleEmailUpdate({
return payload; return payload;
} }
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action( export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging( withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
"updated", const oldObject = await getUser(ctx.user.id);
"user", let payload = buildUserUpdatePayload(parsedInput);
async ({ payload = await handleEmailUpdate({ ctx, parsedInput, payload });
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
// Only proceed with updateUser if we have actual changes to make // Only proceed with updateUser if we have actual changes to make
let newObject = oldObject; let newObject = oldObject;
if (Object.keys(payload).length > 0) { if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload); newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
} }
)
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
})
); );
export const resetPasswordAction = authenticatedActionClient.action( export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging( withAuditLogging("passwordReset", "user", async ({ ctx }) => {
"passwordReset", if (PASSWORD_RESET_DISABLED) {
"user", throw new OperationNotAllowedError("Password reset is disabled");
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
} }
)
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await requestPasswordReset(ctx.user, "profile");
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
})
); );
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal"; import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils"; import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -198,41 +198,54 @@ export const EditProfileDetailsForm = ({
<FormField <FormField
control={form.control} control={form.control}
name="locale" name="locale"
render={({ field }) => ( render={({ field }) => {
<FormItem className="mt-4"> const selectedLanguage = appLanguages.find((l) => l.code === field.value);
<FormLabel>{t("common.language")}</FormLabel>
<FormControl> return (
<DropdownMenu> <FormItem className="mt-4">
<DropdownMenuTrigger asChild> <FormLabel>{t("common.language")}</FormLabel>
<Button <FormControl>
type="button" <DropdownMenu>
variant="ghost" <DropdownMenuTrigger asChild>
className="h-10 w-full border border-slate-300 px-3 text-left"> <Button
<div className="flex w-full items-center justify-between"> type="button"
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"} variant="ghost"
<ChevronDownIcon className="h-4 w-4 text-slate-500" /> className="h-10 w-full border border-slate-300 px-3 text-left">
</div> <div className="flex w-full items-center justify-between">
</Button> {selectedLanguage ? (
</DropdownMenuTrigger> <>
<DropdownMenuContent {selectedLanguage.label["en-US"]}
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700" {selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
align="start"> ` (${selectedLanguage.label.native})`}
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}> </>
{appLanguages.map((lang) => ( ) : (
<DropdownMenuRadioItem t("common.select")
key={lang.code} )}
value={lang.code} <ChevronDownIcon className="h-4 w-4 text-slate-500" />
className="min-h-8 cursor-pointer"> </div>
{lang.label["en-US"]} </Button>
</DropdownMenuRadioItem> </DropdownMenuTrigger>
))} <DropdownMenuContent
</DropdownMenuRadioGroup> className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
</DropdownMenuContent> align="start">
</DropdownMenu> <DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
</FormControl> {sortedAppLanguages.map((lang) => (
<FormError /> <DropdownMenuRadioItem
</FormItem> key={lang.code}
)} value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
);
}}
/> />
{isPasswordResetEnabled && ( {isPasswordResetEnabled && (
@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
aria-label="password" aria-label="password"
aria-required="true" aria-required="true"
required required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm" className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
value={field.value} value={field.value}
onChange={(password) => field.onChange(password)} onChange={(password) => field.onChange(password)}
/> />
@@ -1,3 +1,4 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
@@ -28,7 +29,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user ? await getUser(session.user.id) : null; const user = session?.user ? await getUser(session.user.id) : null;
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email"; const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
@@ -60,7 +61,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
buttons={[ buttons={[
{ {
text: IS_FORMBRICKS_CLOUD text: IS_FORMBRICKS_CLOUD
? t("common.start_free_trial") ? t("common.upgrade_plan")
: t("common.request_trial_license"), : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD href: IS_FORMBRICKS_CLOUD
? `/environments/${params.environmentId}/settings/billing` ? `/environments/${params.environmentId}/settings/billing`
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils"; import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
@@ -25,10 +26,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
); );
if (!session) { if (!session) {
throw new Error(t("common.session_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan); const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const isOwnerOrManager = isManager || isOwner; const isOwnerOrManager = isManager || isOwner;
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id); const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
@@ -0,0 +1,146 @@
"use client";
import type { TFunction } from "i18next";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { Badge } from "@/modules/ui/components/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
type TFeatureDefinition = {
key: TPublicLicenseFeatureKey;
labelKey: string;
docsUrl: string;
};
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
return [
{
key: "contacts",
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
},
{
key: "projects",
labelKey: t("environments.settings.enterprise.license_feature_projects"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
},
{
key: "whitelabel",
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
},
{
key: "removeBranding",
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
},
{
key: "twoFactorAuth",
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
},
{
key: "sso",
labelKey: t("environments.settings.enterprise.license_feature_sso"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
},
{
key: "saml",
labelKey: t("environments.settings.enterprise.license_feature_saml"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
},
{
key: "spamProtection",
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
},
{
key: "auditLogs",
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
},
{
key: "accessControl",
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
},
{
key: "quotas",
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
},
];
};
interface EnterpriseLicenseFeaturesTableProps {
features: TEnterpriseLicenseFeatures;
}
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
const { t } = useTranslation();
return (
<SettingsCard
title={t("environments.settings.enterprise.license_features_table_title")}
description={t("environments.settings.enterprise.license_features_table_description")}
noPadding>
<Table>
<TableHeader>
<TableRow className="hover:bg-white">
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
<TableHead>{t("common.documentation")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{getFeatureDefinitions(t).map((feature) => {
const value = features[feature.key];
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
let displayValue: number | string = "—";
if (typeof value === "number") {
displayValue = value;
} else if (value === null) {
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
}
return (
<TableRow key={feature.key} className="hover:bg-white">
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
<TableCell>
<Badge
type={isEnabled ? "success" : "gray"}
size="normal"
text={
isEnabled
? t("environments.settings.enterprise.license_features_table_enabled")
: t("environments.settings.enterprise.license_features_table_disabled")
}
/>
</TableCell>
<TableCell className="text-slate-600">{displayValue}</TableCell>
<TableCell>
<Link
href={feature.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
{t("common.read_docs")}
</Link>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</SettingsCard>
);
};
@@ -6,22 +6,23 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { 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 { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { SettingsCard } from "../../../components/SettingsCard"; import { SettingsCard } from "../../../components/SettingsCard";
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
interface EnterpriseLicenseStatusProps { interface EnterpriseLicenseStatusProps {
status: LicenseStatus; status: TLicenseStatus;
lastChecked: Date;
gracePeriodEnd?: Date; gracePeriodEnd?: Date;
environmentId: string; environmentId: string;
} }
const getBadgeConfig = ( const getBadgeConfig = (
status: LicenseStatus, status: TLicenseStatus,
t: TFunction t: TFunction
): { type: "success" | "error" | "warning" | "gray"; label: string } => { ): { type: "success" | "error" | "warning" | "gray"; label: string } => {
switch (status) { switch (status) {
@@ -29,6 +30,11 @@ const getBadgeConfig = (
return { type: "success", label: t("environments.settings.enterprise.license_status_active") }; return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
case "expired": case "expired":
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") }; return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
case "instance_mismatch":
return {
type: "error",
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
};
case "unreachable": case "unreachable":
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") }; return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
case "invalid_license": case "invalid_license":
@@ -40,10 +46,12 @@ const getBadgeConfig = (
export const EnterpriseLicenseStatus = ({ export const EnterpriseLicenseStatus = ({
status, status,
lastChecked,
gracePeriodEnd, gracePeriodEnd,
environmentId, environmentId,
}: EnterpriseLicenseStatusProps) => { }: EnterpriseLicenseStatusProps) => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const router = useRouter(); const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false); const [isRechecking, setIsRechecking] = useState(false);
@@ -59,6 +67,8 @@ export const EnterpriseLicenseStatus = ({
if (result?.data) { if (result?.data) {
if (result.data.status === "unreachable") { if (result.data.status === "unreachable") {
toast.error(t("environments.settings.enterprise.recheck_license_unreachable")); toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
} else if (result.data.status === "instance_mismatch") {
toast.error(t("environments.settings.enterprise.recheck_license_instance_mismatch"));
} else if (result.data.status === "invalid_license") { } else if (result.data.status === "invalid_license") {
toast.error(t("environments.settings.enterprise.recheck_license_invalid")); toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
} else { } else {
@@ -86,7 +96,12 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" /> <div className="flex flex-wrap items-center gap-3">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<span className="text-sm text-slate-500">
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
</span>
</div>
</div> </div>
<Button <Button
type="button" type="button"
@@ -112,7 +127,7 @@ export const EnterpriseLicenseStatus = ({
<Alert variant="warning" size="small"> <Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal"> <AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", { {t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, { gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
@@ -128,6 +143,13 @@ export const EnterpriseLicenseStatus = ({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{status === "instance_mismatch" && (
<Alert variant="error" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_instance_mismatch_description")}
</AlertDescription>
</Alert>
)}
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500"> <p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "} {t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a <a
@@ -10,8 +10,9 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
const Page = async (props) => { const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) { if (IS_FORMBRICKS_CLOUD) {
@@ -93,15 +94,19 @@ const Page = async (props) => {
/> />
</PageHeader> </PageHeader>
{hasLicense ? ( {hasLicense ? (
<EnterpriseLicenseStatus <>
status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"} <EnterpriseLicenseStatus
gracePeriodEnd={ status={licenseState.status}
licenseState.status === "unreachable" lastChecked={licenseState.lastChecked}
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS) gracePeriodEnd={
: undefined licenseState.status === "unreachable"
} ? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
environmentId={params.environmentId} : undefined
/> }
environmentId={params.environmentId}
/>
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
</>
) : ( ) : (
<div> <div>
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0"> <div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
@@ -3,6 +3,7 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors"; import { OperationNotAllowedError } from "@formbricks/types/errors";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations"; import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service"; import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -11,13 +12,39 @@ import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/co
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
async function updateOrganizationAction<T extends z.ZodRawShape>({
ctx,
organizationId,
schema,
data,
roles,
}: {
ctx: AuthenticatedActionClientCtx;
organizationId: string;
schema: z.ZodObject<T>;
data: z.infer<z.ZodObject<T>>;
roles: TOrganizationRole[];
}) {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [{ type: "organization", schema, data, roles }],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const oldObject = await getOrganization(organizationId);
const result = await updateOrganization(organizationId, data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
const ZUpdateOrganizationNameAction = z.object({ const ZUpdateOrganizationNameAction = z.object({
organizationId: ZId, organizationId: ZId,
data: ZOrganizationUpdateInput.pick({ name: true }), data: ZOrganizationUpdateInput.pick({ name: true }),
}); });
export const updateOrganizationNameAction = authenticatedActionClient export const updateOrganizationNameAction = authenticatedActionClient
.schema(ZUpdateOrganizationNameAction) .inputSchema(ZUpdateOrganizationNameAction)
.action( .action(
withAuditLogging( withAuditLogging(
"updated", "updated",
@@ -27,27 +54,46 @@ export const updateOrganizationNameAction = authenticatedActionClient
parsedInput, parsedInput,
}: { }: {
ctx: AuthenticatedActionClientCtx; ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>; parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
}) => { }) =>
await checkAuthorizationUpdated({ updateOrganizationAction({
userId: ctx.user.id, ctx,
organizationId: parsedInput.organizationId, organizationId: parsedInput.organizationId,
access: [ schema: ZOrganizationUpdateInput.pick({ name: true }),
{ data: parsedInput.data,
type: "organization", roles: ["owner"],
schema: ZOrganizationUpdateInput.pick({ name: true }), })
data: parsedInput.data, )
roles: ["owner"], );
},
], const ZUpdateOrganizationAISettingsAction = z.object({
}); organizationId: ZId,
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; data: ZOrganizationUpdateInput.pick({ isAISmartToolsEnabled: true, isAIDataAnalysisEnabled: true }),
const oldObject = await getOrganization(parsedInput.organizationId); });
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject; export const updateOrganizationAISettingsAction = authenticatedActionClient
ctx.auditLoggingCtx.newObject = result; .inputSchema(ZUpdateOrganizationAISettingsAction)
return result; .action(
} withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
}) =>
updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId,
schema: ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
}),
data: parsedInput.data,
roles: ["owner", "manager"],
})
) )
); );
@@ -55,11 +101,10 @@ const ZDeleteOrganizationAction = z.object({
organizationId: ZId, organizationId: ZId,
}); });
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action( export const deleteOrganizationAction = authenticatedActionClient
withAuditLogging( .inputSchema(ZDeleteOrganizationAction)
"deleted", .action(
"organization", withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled"); if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
@@ -77,6 +122,5 @@ export const deleteOrganizationAction = authenticatedActionClient.schema(ZDelete
const oldObject = await getOrganization(parsedInput.organizationId); const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject; ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId); return await deleteOrganization(parsedInput.organizationId);
} })
) );
);
@@ -0,0 +1,84 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
interface AISettingsToggleProps {
organization: TOrganization;
membershipRole?: TOrganizationRole;
}
export const AISettingsToggle = ({ organization, membershipRole }: Readonly<AISettingsToggleProps>) => {
const [loadingField, setLoadingField] = useState<string | null>(null);
const { t } = useTranslation();
const router = useRouter();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const canEdit = isOwner || isManager;
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
setLoadingField(field);
try {
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data: { [field]: checked },
});
if (response?.data) {
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(response);
toast.error(errorMessage);
}
} catch {
toast.error(t("common.something_went_wrong"));
} finally {
setLoadingField(null);
}
};
return (
<div>
<AdvancedOptionToggle
isChecked={organization.isAISmartToolsEnabled}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
htmlId="ai-smart-tools-toggle"
title={t("environments.settings.general.ai_smart_tools_enabled")}
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
disabled={loadingField !== null || !canEdit}
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={organization.isAIDataAnalysisEnabled}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("environments.settings.general.ai_data_analysis_enabled")}
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
disabled={loadingField !== null || !canEdit}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</div>
);
};
@@ -107,7 +107,7 @@ const DeleteOrganizationModal = ({
}: DeleteOrganizationModalProps) => { }: DeleteOrganizationModalProps) => {
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const { t } = useTranslation(); const { t } = useTranslation();
const handleInputChange = (e) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value); setInputValue(e.target.value);
}; };
@@ -61,7 +61,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
toast.error(errorMessage); toast.error(errorMessage);
} }
} catch (err) { } catch (err) {
toast.error(`Error: ${err.message}`); toast.error(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`);
} }
}; };
@@ -9,7 +9,9 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge"; import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { AISettingsToggle } from "./components/AISettingsToggle";
import { DeleteOrganization } from "./components/DeleteOrganization"; import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"; import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip"; import { SecurityListTip } from "./components/SecurityListTip";
@@ -25,7 +27,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null; const user = session?.user?.id ? await getUser(session.user.id) : null;
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan); const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled; const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role; const currentUserRole = currentUserMembership?.role;
@@ -59,6 +61,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
membershipRole={currentUserMembership?.role} membershipRole={currentUserMembership?.role}
/> />
</SettingsCard> </SettingsCard>
<SettingsCard
title={t("environments.settings.general.ai_enabled")}
description={t("environments.settings.general.ai_enabled_description")}>
<AISettingsToggle organization={organization} membershipRole={currentUserMembership?.role} />
</SettingsCard>
<EmailCustomizationSettings <EmailCustomizationSettings
organization={organization} organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission} hasWhiteLabelPermission={hasWhiteLabelPermission}
@@ -81,7 +88,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard> </SettingsCard>
)} )}
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" /> <div className="space-y-2">
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
</div>
</PageContentWrapper> </PageContentWrapper>
); );
}; };
@@ -1,10 +1,11 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const Layout = async (props) => { const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -17,15 +18,15 @@ const Layout = async (props) => {
]); ]);
if (!organization) { if (!organization) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), null);
} }
if (!project) { if (!project) {
throw new Error(t("common.workspace_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
if (!session) { if (!session) {
throw new Error(t("common.session_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
return <>{children}</>; return <>{children}</>;
@@ -1,3 +1,3 @@
export const SettingsTitle = ({ title }) => { export const SettingsTitle = ({ title }: { title: string }) => {
return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>; return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>;
}; };
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
const Page = async (props) => { const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params; const params = await props.params;
return redirect(`/environments/${params.environmentId}/settings/profile`); return redirect(`/environments/${params.environmentId}/settings/profile`);
}; };
@@ -23,7 +23,7 @@ const ZGetResponsesAction = z.object({
}); });
export const getResponsesAction = authenticatedActionClient export const getResponsesAction = authenticatedActionClient
.schema(ZGetResponsesAction) .inputSchema(ZGetResponsesAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -57,7 +57,7 @@ const ZGetSurveySummaryAction = z.object({
}); });
export const getSurveySummaryAction = authenticatedActionClient export const getSurveySummaryAction = authenticatedActionClient
.schema(ZGetSurveySummaryAction) .inputSchema(ZGetSurveySummaryAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -85,7 +85,7 @@ const ZGetResponseCountAction = z.object({
}); });
export const getResponseCountAction = authenticatedActionClient export const getResponseCountAction = authenticatedActionClient
.schema(ZGetResponseCountAction) .inputSchema(ZGetResponseCountAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -110,12 +110,12 @@ export const getResponseCountAction = authenticatedActionClient
const ZGetDisplaysWithContactAction = z.object({ const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId, surveyId: ZId,
limit: z.number().int().min(1).max(100), limit: z.int().min(1).max(100),
offset: z.number().int().nonnegative(), offset: z.int().nonnegative(),
}); });
export const getDisplaysWithContactAction = authenticatedActionClient export const getDisplaysWithContactAction = authenticatedActionClient
.schema(ZGetDisplaysWithContactAction) .inputSchema(ZGetDisplaysWithContactAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
type Props = { type Props = {
@@ -14,10 +15,11 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const survey = await getSurvey(params.surveyId); const survey = await getSurvey(params.surveyId);
const responseCount = await getResponseCountBySurveyId(params.surveyId); const responseCount = await getResponseCountBySurveyId(params.surveyId);
const t = await getTranslate();
if (session) { if (session) {
return { return {
title: `${responseCount} Responses | ${survey?.name} Results`, title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`,
}; };
} }
return { return {
@@ -25,7 +27,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
}; };
}; };
const SurveyLayout = async ({ children }) => { const SurveyLayout = async ({ children }: { children: React.ReactNode }) => {
return <ResponseFilterProvider>{children}</ResponseFilterProvider>; return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
}; };
@@ -96,8 +96,8 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0; const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns // Generate columns
const columns = useMemo( const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn), () => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, t, showQuotasColumn] [survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
); );
// Save settings to localStorage when they change // Save settings to localStorage when they change
@@ -205,11 +205,11 @@ export const ResponseTable = ({
}; };
// Handle downloading selected responses // Handle downloading selected responses
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => { const downloadSelectedRows = async (responseIds: string[], format: "xlsx" | "csv") => {
try { try {
const downloadResponse = await getResponsesDownloadUrlAction({ const downloadResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id, surveyId: survey.id,
format: format, format,
filterCriteria: { responseIds }, filterCriteria: { responseIds },
}); });
@@ -300,7 +300,6 @@ export const ResponseTable = ({
<DataTableSettingsModal <DataTableSettingsModal
open={isTableSettingsModalOpen} open={isTableSettingsModalOpen}
setOpen={setIsTableSettingsModalOpen} setOpen={setIsTableSettingsModalOpen}
survey={survey}
table={table} table={table}
columnOrder={columnOrder} columnOrder={columnOrder}
handleDragEnd={handleDragEnd} handleDragEnd={handleDragEnd}
@@ -5,13 +5,14 @@ import { TFunction } from "i18next";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react"; import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { TResponseTableData } from "@formbricks/types/responses"; import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements"; import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils"; import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime"; import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall"; import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse"; import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -34,6 +35,7 @@ const getElementColumnsData = (
element: TSurveyElement, element: TSurveyElement,
survey: TSurvey, survey: TSurvey,
isExpanded: boolean, isExpanded: boolean,
locale: TUserLocale,
t: TFunction t: TFunction
): ColumnDef<TResponseTableData>[] => { ): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t); const ELEMENTS_ICON_MAP = getElementIconMap(t);
@@ -41,7 +43,7 @@ const getElementColumnsData = (
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"]; const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
// Helper function to create consistent column headers // Helper function to create consistent column headers
const createElementHeader = (elementType: string, headline: string, suffix?: string) => { const createElementHeader = (elementType: TSurveyElementTypeEnum, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline; const title = suffix ? `${headline} - ${suffix}` : headline;
const ElementHeader = () => ( const ElementHeader = () => (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -167,6 +169,7 @@ const getElementColumnsData = (
survey={survey} survey={survey}
responseData={responseValue} responseData={responseValue}
language={language} language={language}
locale={locale}
isExpanded={isExpanded} isExpanded={isExpanded}
showId={false} showId={false}
/> />
@@ -218,6 +221,7 @@ const getElementColumnsData = (
survey={survey} survey={survey}
responseData={responseValue} responseData={responseValue}
language={language} language={language}
locale={locale}
isExpanded={isExpanded} isExpanded={isExpanded}
showId={false} showId={false}
/> />
@@ -232,7 +236,7 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
const metadataColumns: ColumnDef<TResponseTableData>[] = []; const metadataColumns: ColumnDef<TResponseTableData>[] = [];
METADATA_FIELDS.forEach((label) => { METADATA_FIELDS.forEach((label) => {
const IconComponent = COLUMNS_ICON_MAP[label]; const IconComponent = COLUMNS_ICON_MAP[label as keyof typeof COLUMNS_ICON_MAP];
metadataColumns.push({ metadataColumns.push({
accessorKey: "METADATA_" + label, accessorKey: "METADATA_" + label,
@@ -259,11 +263,14 @@ export const generateResponseTableColumns = (
survey: TSurvey, survey: TSurvey,
isExpanded: boolean, isExpanded: boolean,
isReadOnly: boolean, isReadOnly: boolean,
locale: TUserLocale,
t: TFunction, t: TFunction,
showQuotasColumn: boolean showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => { ): ColumnDef<TResponseTableData>[] => {
const elements = getElementsFromBlocks(survey.blocks); const elements = getElementsFromBlocks(survey.blocks);
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t)); const elementColumns = elements.flatMap((element) =>
getElementColumnsData(element, survey, isExpanded, locale, t)
);
const dateColumn: ColumnDef<TResponseTableData> = { const dateColumn: ColumnDef<TResponseTableData> = {
accessorKey: "createdAt", accessorKey: "createdAt",
@@ -271,7 +278,7 @@ export const generateResponseTableColumns = (
size: 200, size: 200,
cell: ({ row }) => { cell: ({ row }) => {
const date = new Date(row.original.createdAt); const date = new Date(row.original.createdAt);
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>; return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
}, },
}; };
@@ -1,4 +1,5 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { TFunction } from "i18next";
import { import {
AirplayIcon, AirplayIcon,
ArrowUpFromDotIcon, ArrowUpFromDotIcon,
@@ -38,7 +39,7 @@ describe("utils", () => {
"environments.surveys.responses.source": "Source", "environments.surveys.responses.source": "Source",
}; };
return translations[key] || key; return translations[key] || key;
}); }) as unknown as TFunction;
describe("getAddressFieldLabel", () => { describe("getAddressFieldLabel", () => {
test("returns correct label for addressLine1", () => { test("returns correct label for addressLine1", () => {
@@ -80,9 +80,24 @@ export const COLUMNS_ICON_MAP = {
const userAgentFields = ["browser", "os", "device"]; const userAgentFields = ["browser", "os", "device"];
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"]; export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
export const getMetadataValue = (meta: TResponseMeta, label: string) => { export const getMetadataValue = (
if (userAgentFields.includes(label)) { meta: TResponseMeta,
return meta.userAgent?.[label]; label: (typeof METADATA_FIELDS)[number]
): string | undefined => {
switch (label) {
case "browser":
return meta.userAgent?.browser;
case "os":
return meta.userAgent?.os;
case "device":
return meta.userAgent?.device;
case "action":
return meta.action;
case "country":
return meta.country;
case "source":
return meta.source;
case "url":
return meta.url;
} }
return meta[label];
}; };
@@ -1,3 +1,4 @@
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation"; import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage"; import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -7,7 +8,6 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments"; import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -17,31 +17,30 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => { const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId); const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([ const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
getSurvey(params.surveyId), getSurvey(params.surveyId),
getUser(session.user.id), getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId), getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(), getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId), getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]); ]);
if (!survey) { if (!survey) {
throw new Error(t("common.survey_not_found")); throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
} }
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
if (!organization) { if (!organization) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), null);
} }
const segments = isContactsEnabled ? await getSegments(params.environmentId) : []; const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
@@ -50,10 +49,10 @@ const Page = async (props) => {
const organizationBilling = await getOrganizationBilling(organization.id); const organizationBilling = await getOrganizationBilling(organization.id);
if (!organizationBilling) { if (!organizationBilling) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), organization.id);
} }
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan); const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : []; const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
// Fetch initial responses on the server to prevent duplicate client-side fetch // Fetch initial responses on the server to prevent duplicate client-side fetch
@@ -86,7 +85,7 @@ const Page = async (props) => {
environmentTags={tags} environmentTags={tags}
user={user} user={user}
responsesPerPage={RESPONSES_PER_PAGE} responsesPerPage={RESPONSES_PER_PAGE}
locale={locale} locale={user.locale}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed} isQuotasAllowed={isQuotasAllowed}
quotas={quotas} quotas={quotas}
@@ -7,7 +7,6 @@ import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/s
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { convertToCsv } from "@/lib/utils/file-conversion"; import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -22,7 +21,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
}); });
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
.schema(ZSendEmbedSurveyPreviewEmailAction) .inputSchema(ZSendEmbedSurveyPreviewEmailAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId); const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
@@ -65,57 +64,49 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({ const ZResetSurveyAction = z.object({
surveyId: ZId, surveyId: ZId,
organizationId: ZId,
projectId: ZId, projectId: ZId,
}); });
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action( export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging( withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
"updated", const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
"survey", const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZResetSurveyAction>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; await checkAuthorizationUpdated({
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId; userId: ctx.user.id,
ctx.auditLoggingCtx.oldObject = null; organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey( ctx.auditLoggingCtx.organizationId = organizationId;
parsedInput.surveyId ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
); ctx.auditLoggingCtx.oldObject = null;
ctx.auditLoggingCtx.newObject = { const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
deletedResponsesCount: deletedResponsesCount, parsedInput.surveyId
deletedDisplaysCount: deletedDisplaysCount, );
};
return { ctx.auditLoggingCtx.newObject = {
success: true, deletedResponsesCount: deletedResponsesCount,
deletedResponsesCount: deletedResponsesCount, deletedDisplaysCount: deletedDisplaysCount,
deletedDisplaysCount: deletedDisplaysCount, };
};
} return {
) success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
})
); );
const ZGetEmailHtmlAction = z.object({ const ZGetEmailHtmlAction = z.object({
@@ -123,7 +114,7 @@ const ZGetEmailHtmlAction = z.object({
}); });
export const getEmailHtmlAction = authenticatedActionClient export const getEmailHtmlAction = authenticatedActionClient
.schema(ZGetEmailHtmlAction) .inputSchema(ZGetEmailHtmlAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -152,9 +143,10 @@ const ZGeneratePersonalLinksAction = z.object({
}); });
export const generatePersonalLinksAction = authenticatedActionClient export const generatePersonalLinksAction = authenticatedActionClient
.schema(ZGeneratePersonalLinksAction) .inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled(); const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) { if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment"); throw new OperationNotAllowedError("Contacts are not enabled for this environment");
} }
@@ -231,7 +223,7 @@ const ZUpdateSingleUseLinksAction = z.object({
}); });
export const updateSingleUseLinksAction = authenticatedActionClient export const updateSingleUseLinksAction = authenticatedActionClient
.schema(ZUpdateSingleUseLinksAction) .inputSchema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -30,8 +30,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.booked.count}{" "} {t("common.count_responses", { count: elementSummary.booked.count })}
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
@@ -47,8 +46,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.skipped.count}{" "} {t("common.count_responses", { count: elementSummary.skipped.count })}
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")} {t("common.count_responses", { count: summaryItem.count })}
</p> </p>
</div> </div>
<div className="group-hover:opacity-80"> <div className="group-hover:opacity-80">
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateWithOrdinal } from "@/lib/utils/datetime"; import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state"; import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,13 +32,14 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
}; };
const renderResponseValue = (value: string) => { const renderResponseValue = (value: string) => {
const parsedDate = new Date(value); const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
const formattedDate = isNaN(parsedDate.getTime()) return (
? `${t("common.invalid_date")}(${value})` formattedDate ??
: formatDateWithOrdinal(parsedDate); t("common.invalid_date_with_value", {
value,
return formattedDate; })
);
}; };
return ( return (
@@ -59,7 +60,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
elementSummary.samples.slice(0, visibleResponses).map((response) => ( elementSummary.samples.slice(0, visibleResponses).map((response) => (
<div <div
key={response.id} key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base"> className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
<div className="pl-4 md:pl-6"> <div className="pl-4 md:pl-6">
{response.contact ? ( {response.contact ? (
<Link <Link
@@ -84,7 +85,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold"> <div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)} {renderResponseValue(response.value)}
</div> </div>
<div className="px-4 text-slate-500 md:px-6"> <div className="px-4 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)} {timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div> </div>
</div> </div>
@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
{showResponses && ( {showResponses && (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.responseCount} ${t("common.responses")}`} {t("common.count_responses", { count: elementSummary.responseCount })}
</div> </div>
)} )}
{additionalInfo} {additionalInfo}
@@ -41,8 +41,7 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
</div> </div>
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{elementSummary.responseCount}{" "} {t("common.count_responses", { count: elementSummary.responseCount })}
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
</div> </div>
</div> </div>
</div> </div>
@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
if (label) { if (label) {
return label; return label;
} else if (percentage !== undefined && totalResponsesForRow !== undefined) { } else if (percentage !== undefined && totalResponsesForRow !== undefined) {
return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`; return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) });
} }
return ""; return "";
}; };
@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
)}> )}>
<button <button
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }} style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline" className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark"
onClick={() => onClick={() =>
setFilter( setFilter(
elementSummary.element.id, elementSummary.element.id,
@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
elementSummary.type === "multipleChoiceMulti" ? ( elementSummary.type === "multipleChoiceMulti" ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.selectionCount} ${t("common.selections")}`} {t("common.count_selections", { count: elementSummary.selectionCount })}
</div> </div>
) : undefined ) : undefined
} }
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
</div> </div>
<div className="flex w-full space-x-2"> <div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0"> <p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")} {t("common.count_selections", { count: result.count })}
</p> </p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700"> <p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}% {convertFloatToNDecimal(result.percentage, 2)}%
@@ -60,7 +60,9 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
}, },
}; };
const filter = filters[group]; const filter = (filters as Record<string, { comparison: string; values: string | string[] | undefined }>)[
group
];
if (filter) { if (filter) {
setFilter( setFilter(
@@ -104,7 +106,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<TabsContent value="aggregated" className="mt-4"> <TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6"> <div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base"> <div className="space-y-5 text-sm md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => ( {(["promoters", "passives", "detractors", "dismissed"] as const).map((group) => (
<button <button
className="w-full cursor-pointer hover:opacity-80" className="w-full cursor-pointer hover:opacity-80"
key={group} key={group}
@@ -123,8 +125,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary[group]?.count}{" "} {t("common.count_responses", { count: elementSummary[group]?.count })}
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar <ProgressBar
@@ -158,7 +159,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
}> }>
<div className="flex h-32 w-full flex-col items-center justify-end"> <div className="flex h-32 w-full flex-col items-center justify-end">
<div <div
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110" className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110"
style={{ style={{
height: `${Math.max(choice.percentage, 2)}%`, height: `${Math.max(choice.percentage, 2)}%`,
opacity, opacity,
@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
elementSummary.element.allowMulti ? ( elementSummary.element.allowMulti ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.selectionCount} ${t("common.selections")}`} {t("common.count_selections", { count: elementSummary.selectionCount })}
</div> </div>
) : undefined ) : undefined
} }
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
</div> </div>
<div className="flex w-full space-x-2"> <div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0"> <p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")} {t("common.count_selections", { count: result.count })}
</p> </p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700"> <p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}% {convertFloatToNDecimal(result.percentage, 2)}%
@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
) )
}> }>
<div <div
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`} className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }} style={{ opacity }}
/> />
</ClickableBarSegment> </ClickableBarSegment>
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")} {t("common.count_responses", { count: result.count })}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
@@ -215,8 +215,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
<div className="text flex justify-between px-2"> <div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p> <p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.dismissed.count}{" "} {t("common.count_responses", { count: elementSummary.dismissed.count })}
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
</div> </div>
@@ -15,7 +15,7 @@ interface SummaryMetadataProps {
isQuotasAllowed: boolean; isQuotasAllowed: boolean;
} }
const formatTime = (ttc) => { const formatTime = (ttc: number) => {
const seconds = ttc / 1000; const seconds = ttc / 1000;
let formattedValue; let formattedValue;
@@ -105,7 +105,7 @@ export const SummaryPage = ({
setDisplays(data); setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE); setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) { } catch (error) {
toast.error(error); toast.error(error instanceof Error ? error.message : String(error));
setDisplays([]); setDisplays([]);
setHasMoreDisplays(false); setHasMoreDisplays(false);
} finally { } finally {
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false); const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false); const [isResetting, setIsResetting] = useState(false);
const { organizationId, project } = useEnvironment(); const { project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly); const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted; const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,7 +128,6 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true); setIsResetting(true);
const result = await resetSurveyAction({ const result = await resetSurveyAction({
surveyId: survey.id, surveyId: survey.id,
organizationId: organizationId,
projectId: project.id, projectId: project.id,
}); });
if (result?.data) { if (result?.data) {
@@ -75,17 +75,7 @@ export const ShareSurveyModal = ({
const [showView, setShowView] = useState<ModalView>(modalView); const [showView, setShowView] = useState<ModalView>(modalView);
const { email } = user; const { email } = user;
const { t } = useTranslation(); const { t } = useTranslation();
const linkTabs: { const linkTabs = useMemo(() => {
id: ShareViaType | ShareSettingsType;
type: LinkTabsType;
label: string;
icon: React.ElementType;
title: string;
description: string;
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
}[] = useMemo(() => {
const tabs = [ const tabs = [
{ {
id: ShareViaType.ANON_LINKS, id: ShareViaType.ANON_LINKS,
@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
}, },
{ {
title: t("environments.surveys.share.anonymous_links.custom_start_point"), title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question", href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
}, },
]} ]}
/> />
@@ -47,6 +47,7 @@ const createNoCodeConfigType = (t: ReturnType<typeof useTranslation>["t"]) => ({
pageView: t("environments.actions.page_view"), pageView: t("environments.actions.page_view"),
exitIntent: t("environments.actions.exit_intent"), exitIntent: t("environments.actions.exit_intent"),
fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"), fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"),
pageDwell: t("environments.actions.time_on_page"),
}); });
const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => { const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => {
@@ -105,7 +105,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
<div className={scriptsMode === "replace" ? "opacity-50" : ""}> <div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel> <FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3"> <div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600"> <pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
{projectCustomScripts} {projectCustomScripts}
</pre> </pre>
</div> </div>
@@ -135,7 +135,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
rows={8} rows={8}
placeholder={t("environments.surveys.share.custom_html.placeholder")} placeholder={t("environments.surveys.share.custom_html.placeholder")}
className={cn( className={cn(
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" "flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
)} )}
{...field} {...field}
disabled={isReadOnly} disabled={isReadOnly}
@@ -165,7 +165,7 @@ export const PersonalLinksTab = ({
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")} description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
buttons={[ buttons={[
{ {
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"), text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing` ? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license", : "https://formbricks.com/upgrade-self-hosting-license",
@@ -39,7 +39,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
} }
} }
} catch (error) { } catch (error) {
logger.error("Failed to generate QR code:", error); logger.error(error as Error, "Failed to generate QR code");
setHasError(true); setHasError(true);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -66,7 +66,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
downloadInstance.download({ name: "survey-qr-code", extension: "png" }); downloadInstance.download({ name: "survey-qr-code", extension: "png" });
toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon")); toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon"));
} catch (error) { } catch (error) {
logger.error("Failed to download QR code:", error); logger.error(error as Error, "Failed to download QR code");
toast.error(t("environments.surveys.summary.qr_code_download_failed")); toast.error(t("environments.surveys.summary.qr_code_download_failed"));
} finally { } finally {
setIsDownloading(false); setIsDownloading(false);
@@ -4,6 +4,10 @@ import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import {
ShareSettingsType,
ShareViaType,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink"; import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
@@ -13,9 +17,9 @@ interface SuccessViewProps {
publicDomain: string; publicDomain: string;
setSurveyUrl: (url: string) => void; setSurveyUrl: (url: string) => void;
user: TUser; user: TUser;
tabs: { id: string; label: string; icon: React.ElementType }[]; tabs: { id: ShareViaType | ShareSettingsType; label: string; icon: React.ElementType }[];
handleViewChange: (view: string) => void; handleViewChange: (view: "start" | "share") => void;
handleEmbedViewWithTab: (tabId: string) => void; handleEmbedViewWithTab: (tabId: ShareViaType | ShareSettingsType) => void;
isReadOnly: boolean; isReadOnly: boolean;
} }
@@ -66,7 +70,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8"> className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" /> <UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.use_personal_links")} {t("environments.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} /> <Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
</button> </button>
<Link <Link
href={`/environments/${environmentId}/settings/notifications`} href={`/environments/${environmentId}/settings/notifications`}
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
@@ -9,11 +10,11 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
const t = await getTranslate(); const t = await getTranslate();
const survey = await getSurvey(surveyId); const survey = await getSurvey(surveyId);
if (!survey) { if (!survey) {
throw new Error("Survey not found"); throw new ResourceNotFoundError(t("common.survey"), surveyId);
} }
const project = await getProjectByEnvironmentId(survey.environmentId); const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) { if (!project) {
throw new Error("Workspace not found"); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
const styling = getStyling(project, survey); const styling = getStyling(project, survey);
@@ -96,7 +96,7 @@ describe("Tests for getQuotasSummary service", () => {
_count: { _count: {
quotaLinks: 0, quotaLinks: 0,
}, },
}, } as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
]); ]);
const result = await getQuotasSummary(surveyId); const result = await getQuotasSummary(surveyId);
@@ -120,7 +120,7 @@ describe("Tests for getQuotasSummary service", () => {
_count: { _count: {
quotaLinks: 0, quotaLinks: 0,
}, },
}, } as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
]); ]);
const result = await getQuotasSummary(surveyId); const result = await getQuotasSummary(surveyId);
@@ -11,8 +11,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils"; import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { import {
getElementSummary, getElementSummary,
getResponsesForSummary, getResponsesForSummary,
@@ -44,7 +43,7 @@ vi.mock("@/lib/survey/service", () => ({
})); }));
vi.mock("@/lib/surveyLogic/utils", () => ({ vi.mock("@/lib/surveyLogic/utils", () => ({
evaluateLogic: vi.fn(), evaluateLogic: vi.fn(),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })), performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })),
})); }));
vi.mock("@/lib/utils/validate", () => ({ vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(), validateInputs: vi.fn(),
@@ -229,12 +228,6 @@ describe("getSurveySummaryDropOff", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) => vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0 num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
); );
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
vi.mocked(performActions).mockReturnValue({
jumpTarget: undefined,
requiredElementIds: [],
calculations: {},
});
}); });
test("calculates dropOff correctly with welcome card disabled", () => { test("calculates dropOff correctly with welcome card disabled", () => {
@@ -246,7 +239,7 @@ describe("getSurveySummaryDropOff", () => {
contact: null, contact: null,
contactAttributes: {}, contactAttributes: {},
language: "en", language: "en",
ttc: { q1: 10 }, ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it
finished: false, finished: false,
}, // Dropped at q2 }, // Dropped at q2
{ {
@@ -269,22 +262,55 @@ describe("getSurveySummaryDropOff", () => {
); );
expect(dropOff.length).toBe(2); expect(dropOff.length).toBe(2);
// Q1 // Q1: welcome card disabled so impressions = displayCount
expect(dropOff[0].elementId).toBe("q1"); expect(dropOff[0].elementId).toBe("q1");
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount expect(dropOff[0].impressions).toBe(displayCount);
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1 expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100 expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
expect(dropOff[0].ttc).toBe(10); expect(dropOff[0].ttc).toBe(10);
// Q2 // Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2)
expect(dropOff[1].elementId).toBe("q2"); expect(dropOff[1].elementId).toBe("q2");
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2 expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2 expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100 expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(10); expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
}); });
test("handles logic jumps", () => { test("drop-off attributed to last seen element when user doesn't reach next question", () => {
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
const surveyWithWelcome: TSurvey = {
...surveyWithBlocks,
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
};
const responses = [
{
id: "r1",
data: { q1: "a" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10 }, // Only saw q1, never reached q2
finished: false,
},
] as any;
const displayCount = 1;
const dropOff = getSurveySummaryDropOff(
surveyWithWelcome,
getElementsFromBlocks(surveyWithWelcome.blocks),
responses,
displayCount
);
expect(dropOff[0].impressions).toBe(1); // Saw q1
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
expect(dropOff[1].impressions).toBe(0); // Never saw q2
expect(dropOff[1].dropOffCount).toBe(0);
});
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
const surveyWithLogic: TSurvey = { const surveyWithLogic: TSurvey = {
...mockBaseSurvey, ...mockBaseSurvey,
blocks: [ blocks: [
@@ -315,36 +341,6 @@ describe("getSurveySummaryDropOff", () => {
charLimit: { enabled: false }, charLimit: { enabled: false },
}, },
] as TSurveyElement[], ] as TSurveyElement[],
logic: [
{
id: "logic1",
conditions: {
id: "condition1",
connector: "and" as const,
conditions: [
{
id: "c1",
leftOperand: {
type: "element" as const,
value: "q2",
},
operator: "equals" as const,
rightOperand: {
type: "static" as const,
value: "b",
},
},
],
},
actions: [
{
id: "action1",
objective: "jumpToBlock" as const,
target: "q4",
},
],
},
],
}, },
{ {
id: "block3", id: "block3",
@@ -377,28 +373,21 @@ describe("getSurveySummaryDropOff", () => {
], ],
questions: [], questions: [],
}; };
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
const responses = [ const responses = [
{ {
id: "r1", id: "r1",
data: { q1: "a", q2: "b" }, data: { q1: "a", q2: "b", q4: "d" },
updatedAt: new Date(), updatedAt: new Date(),
contact: null, contact: null,
contactAttributes: {}, contactAttributes: {},
language: "en", language: "en",
ttc: { q1: 10, q2: 10 }, ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic
finished: false, finished: false,
}, // Jumps from q2 to q4, drops at q4 },
]; ];
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
// Simulate logic on q2 triggering
return data.q2 === "b";
});
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
}
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
});
const dropOff = getSurveySummaryDropOff( const dropOff = getSurveySummaryDropOff(
surveyWithLogic, surveyWithLogic,
@@ -407,11 +396,11 @@ describe("getSurveySummaryDropOff", () => {
1 1
); );
expect(dropOff[0].impressions).toBe(1); // q1 expect(dropOff[0].impressions).toBe(1); // q1: seen
expect(dropOff[1].impressions).toBe(1); // q2 expect(dropOff[1].impressions).toBe(1); // q2: seen
expect(dropOff[2].impressions).toBe(0); // q3 (skipped) expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data)
expect(dropOff[3].impressions).toBe(1); // q4 (jumped to) expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished)
}); });
}); });
@@ -662,17 +651,23 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3); expect((summary[0] as any).choices).toHaveLength(3);
// Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5 // Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1"); const item1 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(2); expect(item1.count).toBe(2);
expect(item1.avgRanking).toBe(1.5); expect(item1.avgRanking).toBe(1.5);
// Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5 // Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2"); const item2 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(2); expect(item2.count).toBe(2);
expect(item2.avgRanking).toBe(1.5); expect(item2.avgRanking).toBe(1.5);
// Item 3 is in position 3 twice, so avg ranking should be 3 // Item 3 is in position 3 twice, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3"); const item3 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(2); expect(item3.count).toBe(2);
expect(item3.avgRanking).toBe(3); expect(item3.avgRanking).toBe(3);
}); });
@@ -747,17 +742,23 @@ describe("getQuestionSummary", () => {
expect(summary[0].responseCount).toBe(1); expect(summary[0].responseCount).toBe(1);
// Item 1 is in position 2, so avg ranking should be 2 // Item 1 is in position 2, so avg ranking should be 2
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1"); const item1 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(1); expect(item1.count).toBe(1);
expect(item1.avgRanking).toBe(2); expect(item1.avgRanking).toBe(2);
// Item 2 is in position 1, so avg ranking should be 1 // Item 2 is in position 1, so avg ranking should be 1
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2"); const item2 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(1); expect(item2.count).toBe(1);
expect(item2.avgRanking).toBe(1); expect(item2.avgRanking).toBe(1);
// Item 3 is in position 3, so avg ranking should be 3 // Item 3 is in position 3, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3"); const item3 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(1); expect(item3.count).toBe(1);
expect(item3.avgRanking).toBe(3); expect(item3.avgRanking).toBe(3);
}); });
@@ -830,10 +831,12 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3); expect((summary[0] as any).choices).toHaveLength(3);
// All items should have count 0 and avgRanking 0 // All items should have count 0 and avgRanking 0
(summary[0] as any).choices.forEach((choice) => { (summary[0] as any).choices.forEach(
expect(choice.count).toBe(0); (choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.avgRanking).toBe(0); expect(choice.count).toBe(0);
}); expect(choice.avgRanking).toBe(0);
}
);
}); });
test("getQuestionSummary handles ranking question with non-array answers", async () => { test("getQuestionSummary handles ranking question with non-array answers", async () => {
@@ -894,10 +897,12 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3); expect((summary[0] as any).choices).toHaveLength(3);
// All items should have count 0 and avgRanking 0 since we had no valid ranking data // All items should have count 0 and avgRanking 0 since we had no valid ranking data
(summary[0] as any).choices.forEach((choice) => { (summary[0] as any).choices.forEach(
expect(choice.count).toBe(0); (choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.avgRanking).toBe(0); expect(choice.count).toBe(0);
}); expect(choice.avgRanking).toBe(0);
}
);
}); });
test("getQuestionSummary handles ranking question with values not in choices", async () => { test("getQuestionSummary handles ranking question with values not in choices", async () => {
@@ -958,17 +963,23 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3); expect((summary[0] as any).choices).toHaveLength(3);
// Item 1 is in position 1, so avg ranking should be 1 // Item 1 is in position 1, so avg ranking should be 1
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1"); const item1 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(1); expect(item1.count).toBe(1);
expect(item1.avgRanking).toBe(1); expect(item1.avgRanking).toBe(1);
// Item 2 was not ranked, so should have count 0 and avgRanking 0 // Item 2 was not ranked, so should have count 0 and avgRanking 0
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2"); const item2 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(0); expect(item2.count).toBe(0);
expect(item2.avgRanking).toBe(0); expect(item2.avgRanking).toBe(0);
// Item 3 is in position 3, so avg ranking should be 3 // Item 3 is in position 3, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3"); const item3 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(1); expect(item3.count).toBe(1);
expect(item3.avgRanking).toBe(3); expect(item3.avgRanking).toBe(3);
}); });
@@ -986,7 +997,11 @@ describe("getSurveySummary", () => {
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany // Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
// which is used by the actual implementation of getResponsesForSummary. // which is used by the actual implementation of getResponsesForSummary.
vi.mocked(prisma.response.findMany).mockResolvedValue( vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any mockResponses.map((r: Record<string, unknown>) => ({
...r,
contactId: null,
personAttributes: {},
})) as any
); );
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10); vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10);
@@ -1020,8 +1035,8 @@ describe("getSurveySummary", () => {
test("handles filterCriteria", async () => { test("handles filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = { finished: true }; const filterCriteria: TResponseFilterCriteria = { finished: true };
const finishedResponses = mockResponses const finishedResponses = mockResponses
.filter((r) => r.finished) .filter((r: Record<string, unknown>) => r.finished)
.map((r) => ({ ...r, contactId: null, personAttributes: {} })); .map((r: Record<string, unknown>) => ({ ...r, contactId: null, personAttributes: {} }));
vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any); vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any);
await getSurveySummary(mockSurveyId, filterCriteria); await getSurveySummary(mockSurveyId, filterCriteria);
@@ -1043,7 +1058,11 @@ describe("getResponsesForSummary", () => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey); vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
vi.mocked(prisma.response.findMany).mockResolvedValue( vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any mockResponses.map((r: Record<string, unknown>) => ({
...r,
contactId: null,
personAttributes: {},
})) as any
); );
// React cache is already mocked globally - no need to mock it again // React cache is already mocked globally - no need to mock it again
}); });
@@ -1842,23 +1861,63 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2); expect(summary[0].responseCount).toBe(2);
// Verify Speed row // Verify Speed row
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2); expect(speedRow.totalResponsesForRow).toBe(2);
expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50); expect(
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50); speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(50);
expect(
speedRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
// Verify Quality row // Verify Quality row
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(2); expect(qualityRow.totalResponsesForRow).toBe(2);
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(50); expect(
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50); qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Excellent"
).percentage
).toBe(50);
expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Good"
).percentage
).toBe(50);
// Verify Price row // Verify Price row
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price"); const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(2); expect(priceRow.totalResponsesForRow).toBe(2);
expect(priceRow.columnPercentages.find((col) => col.column === "Poor").percentage).toBe(50); expect(
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50); priceRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Poor")
.percentage
).toBe(50);
expect(
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
}); });
test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => { test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => {
@@ -1949,19 +2008,48 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(1); expect(summary[0].responseCount).toBe(1);
// Verify Speed row with localized values mapped to default language // Verify Speed row with localized values mapped to default language
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1); expect(speedRow.totalResponsesForRow).toBe(1);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Verify Quality row // Verify Quality row
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(1); expect(qualityRow.totalResponsesForRow).toBe(1);
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(100); expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Excellent"
).percentage
).toBe(100);
// Verify Price row // Verify Price row
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price"); const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(1); expect(priceRow.totalResponsesForRow).toBe(1);
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100); expect(
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(100);
}); });
test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => { test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => {
@@ -2055,12 +2143,18 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property
// All rows should have zero responses for all columns // All rows should have zero responses for all columns
summary[0].data.forEach((row) => { summary[0].data.forEach(
expect(row.totalResponsesForRow).toBe(0); (row: {
row.columnPercentages.forEach((col) => { rowLabel: string;
expect(col.percentage).toBe(0); totalResponsesForRow: number;
}); columnPercentages: { column: string; percentage: number }[];
}); }) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
}
);
}); });
test("getQuestionSummary handles partial and incomplete matrix responses", async () => { test("getQuestionSummary handles partial and incomplete matrix responses", async () => {
@@ -2147,22 +2241,59 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2); expect(summary[0].responseCount).toBe(2);
// Verify Speed row - both responses provided data // Verify Speed row - both responses provided data
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2); expect(speedRow.totalResponsesForRow).toBe(2);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50); expect(
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50); speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(50);
expect(
speedRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
// Verify Quality row - only one response provided data // Verify Quality row - only one response provided data
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(1); expect(qualityRow.totalResponsesForRow).toBe(1);
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Good"
).percentage
).toBe(100);
// Verify Price row - both responses provided data // Verify Price row - both responses provided data
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price"); const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(2); expect(priceRow.totalResponsesForRow).toBe(2);
// ExtraRow should not appear in the summary // ExtraRow should not appear in the summary
expect(summary[0].data.find((row) => row.rowLabel === "ExtraRow")).toBeUndefined(); expect(
summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "ExtraRow"
)
).toBeUndefined();
}); });
test("getQuestionSummary handles zero responses for Matrix question correctly", async () => { test("getQuestionSummary handles zero responses for Matrix question correctly", async () => {
@@ -2221,12 +2352,18 @@ describe("Matrix question type tests", () => {
// All rows should have proper structure but zero counts // All rows should have proper structure but zero counts
expect(summary[0].data).toHaveLength(2); // 2 rows expect(summary[0].data).toHaveLength(2); // 2 rows
summary[0].data.forEach((row) => { summary[0].data.forEach(
expect(row.columnPercentages).toHaveLength(2); // 2 columns (row: {
expect(row.totalResponsesForRow).toBe(0); rowLabel: string;
expect(row.columnPercentages[0].percentage).toBe(0); totalResponsesForRow: number;
expect(row.columnPercentages[1].percentage).toBe(0); columnPercentages: { column: string; percentage: number }[];
}); }) => {
expect(row.columnPercentages).toHaveLength(2); // 2 columns
expect(row.totalResponsesForRow).toBe(0);
expect(row.columnPercentages[0].percentage).toBe(0);
expect(row.columnPercentages[1].percentage).toBe(0);
}
);
}); });
test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => { test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => {
@@ -2296,21 +2433,46 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(1); expect(summary[0].responseCount).toBe(1);
// Speed row should have a valid response // Speed row should have a valid response
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1); expect(speedRow.totalResponsesForRow).toBe(1);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no valid responses // Quality row should have no valid responses
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0); expect(qualityRow.totalResponsesForRow).toBe(0);
qualityRow.columnPercentages.forEach((col) => { qualityRow.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0); expect(col.percentage).toBe(0);
}); });
// Price row should have a valid response // Price row should have a valid response
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price"); const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(1); expect(priceRow.totalResponsesForRow).toBe(1);
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100); expect(
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(100);
}); });
test("getQuestionSummary handles Matrix question with invalid row labels", async () => { test("getQuestionSummary handles Matrix question with invalid row labels", async () => {
@@ -2381,17 +2543,48 @@ describe("Matrix question type tests", () => {
expect(summary[0].data).toHaveLength(2); // 2 rows expect(summary[0].data).toHaveLength(2); // 2 rows
// Speed row should have a valid response // Speed row should have a valid response
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1); expect(speedRow.totalResponsesForRow).toBe(1);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no responses // Quality row should have no responses
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0); expect(qualityRow.totalResponsesForRow).toBe(0);
// Invalid rows should not appear in the summary // Invalid rows should not appear in the summary
expect(summary[0].data.find((row) => row.rowLabel === "InvalidRow")).toBeUndefined(); expect(
expect(summary[0].data.find((row) => row.rowLabel === "AnotherInvalidRow")).toBeUndefined(); summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "InvalidRow"
)
).toBeUndefined();
expect(
summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "AnotherInvalidRow"
)
).toBeUndefined();
}); });
test("getQuestionSummary handles Matrix question with mixed language responses", async () => { test("getQuestionSummary handles Matrix question with mixed language responses", async () => {
@@ -2493,12 +2686,27 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2); expect(summary[0].responseCount).toBe(2);
// Speed row should have both responses // Speed row should have both responses
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2); expect(speedRow.totalResponsesForRow).toBe(2);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no responses // Quality row should have no responses
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0); expect(qualityRow.totalResponsesForRow).toBe(0);
}); });
@@ -2557,12 +2765,18 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(0); // Counts as response even with null data expect(summary[0].responseCount).toBe(0); // Counts as response even with null data
// Both rows should have zero responses // Both rows should have zero responses
summary[0].data.forEach((row) => { summary[0].data.forEach(
expect(row.totalResponsesForRow).toBe(0); (row: {
row.columnPercentages.forEach((col) => { rowLabel: string;
expect(col.percentage).toBe(0); totalResponsesForRow: number;
}); columnPercentages: { column: string; percentage: number }[];
}); }) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
}
);
}); });
}); });
@@ -2994,23 +3208,33 @@ describe("Rating question type tests", () => {
expect(summary[0].average).toBe(4.25); expect(summary[0].average).toBe(4.25);
// Verify each rating option count and percentage // Verify each rating option count and percentage
const rating5 = summary[0].choices.find((c) => c.rating === 5); const rating5 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 5
);
expect(rating5.count).toBe(2); expect(rating5.count).toBe(2);
expect(rating5.percentage).toBe(50); // 2/4 * 100 expect(rating5.percentage).toBe(50); // 2/4 * 100
const rating4 = summary[0].choices.find((c) => c.rating === 4); const rating4 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 4
);
expect(rating4.count).toBe(1); expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25); // 1/4 * 100 expect(rating4.percentage).toBe(25); // 1/4 * 100
const rating3 = summary[0].choices.find((c) => c.rating === 3); const rating3 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 3
);
expect(rating3.count).toBe(1); expect(rating3.count).toBe(1);
expect(rating3.percentage).toBe(25); // 1/4 * 100 expect(rating3.percentage).toBe(25); // 1/4 * 100
const rating2 = summary[0].choices.find((c) => c.rating === 2); const rating2 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 2
);
expect(rating2.count).toBe(0); expect(rating2.count).toBe(0);
expect(rating2.percentage).toBe(0); expect(rating2.percentage).toBe(0);
const rating1 = summary[0].choices.find((c) => c.rating === 1); const rating1 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 1
);
expect(rating1.count).toBe(0); expect(rating1.count).toBe(0);
expect(rating1.percentage).toBe(0); expect(rating1.percentage).toBe(0);
@@ -3154,10 +3378,12 @@ describe("Rating question type tests", () => {
expect(summary[0].average).toBe(0); expect(summary[0].average).toBe(0);
// Verify all ratings have 0 count and percentage // Verify all ratings have 0 count and percentage
summary[0].choices.forEach((choice) => { summary[0].choices.forEach(
expect(choice.count).toBe(0); (choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.percentage).toBe(0); expect(choice.count).toBe(0);
}); expect(choice.percentage).toBe(0);
}
);
// Verify dismissed is 0 // Verify dismissed is 0
expect(summary[0].dismissed.count).toBe(0); expect(summary[0].dismissed.count).toBe(0);
@@ -3232,15 +3458,21 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3 expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3
// Check individual choice counts // Check individual choice counts
const img1 = summary[0].choices.find((c) => c.id === "img1"); const img1 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
);
expect(img1.count).toBe(1); expect(img1.count).toBe(1);
expect(img1.percentage).toBe(50); expect(img1.percentage).toBe(50);
const img2 = summary[0].choices.find((c) => c.id === "img2"); const img2 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
);
expect(img2.count).toBe(1); expect(img2.count).toBe(1);
expect(img2.percentage).toBe(50); expect(img2.percentage).toBe(50);
const img3 = summary[0].choices.find((c) => c.id === "img3"); const img3 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img3"
);
expect(img3.count).toBe(1); expect(img3.count).toBe(1);
expect(img3.percentage).toBe(50); expect(img3.percentage).toBe(50);
}); });
@@ -3311,10 +3543,12 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(0); expect(summary[0].selectionCount).toBe(0);
// All choices should have zero count // All choices should have zero count
summary[0].choices.forEach((choice) => { summary[0].choices.forEach(
expect(choice.count).toBe(0); (choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.percentage).toBe(0); expect(choice.count).toBe(0);
}); expect(choice.percentage).toBe(0);
}
);
}); });
test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => { test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => {
@@ -3373,17 +3607,23 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one
// img1 should be counted // img1 should be counted
const img1 = summary[0].choices.find((c) => c.id === "img1"); const img1 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
);
expect(img1.count).toBe(1); expect(img1.count).toBe(1);
expect(img1.percentage).toBe(100); expect(img1.percentage).toBe(100);
// img2 should not be counted // img2 should not be counted
const img2 = summary[0].choices.find((c) => c.id === "img2"); const img2 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
);
expect(img2.count).toBe(0); expect(img2.count).toBe(0);
expect(img2.percentage).toBe(0); expect(img2.percentage).toBe(0);
// Invalid ID should not appear in choices // Invalid ID should not appear in choices
expect(summary[0].choices.find((c) => c.id === "invalid-id")).toBeUndefined(); expect(
summary[0].choices.find((c: { id: string; count: number; percentage: number }) => c.id === "invalid-id")
).toBeUndefined();
}); });
}); });
@@ -11,14 +11,9 @@ import {
TResponseData, TResponseData,
TResponseFilterCriteria, TResponseFilterCriteria,
TResponseTtc, TResponseTtc,
TResponseVariables,
ZResponseFilterCriteria, ZResponseFilterCriteria,
} from "@formbricks/types/responses"; } from "@formbricks/types/responses";
import { import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
TSurveyElement,
TSurveyElementChoice,
TSurveyElementTypeEnum,
} from "@formbricks/types/surveys/elements";
import { import {
TSurvey, TSurvey,
TSurveyElementSummaryAddress, TSurveyElementSummaryAddress,
@@ -41,8 +36,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { buildWhereClause } from "@/lib/response/utils"; import { buildWhereClause } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils"; import { getElementsFromBlocks } from "@/lib/survey/utils";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { convertFloatTo2Decimal } from "./utils"; import { convertFloatTo2Decimal } from "./utils";
@@ -97,63 +91,13 @@ export const getSurveySummaryMeta = (
}; };
}; };
const evaluateLogicAndGetNextElementId = ( // Determine whether a response interacted with a given element.
localSurvey: TSurvey, // An element was "seen" if the respondent has a ttc entry for it OR provided an answer.
elements: TSurveyElement[], // This is more reliable than replaying survey logic, which can misattribute impressions
data: TResponseData, // when branching logic skips elements or when partial response data is insufficient
localVariables: TResponseVariables, // to evaluate conditions correctly.
currentElementIndex: number, const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => {
currElementTemp: TSurveyElement, return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined;
selectedLanguage: string | null
): {
nextElementId: string | undefined;
updatedSurvey: TSurvey;
updatedVariables: TResponseVariables;
} => {
let updatedSurvey = { ...localSurvey };
let updatedVariables = { ...localVariables };
let firstJumpTarget: string | undefined;
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
if (currentBlock?.logic && currentBlock.logic.length > 0) {
for (const logic of currentBlock.logic) {
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
const { jumpTarget, requiredElementIds, calculations } = performActions(
updatedSurvey,
logic.actions,
data,
updatedVariables
);
if (requiredElementIds.length > 0) {
// Update blocks to mark elements as required
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
...block,
elements: block.elements.map((e) =>
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
),
}));
}
updatedVariables = { ...updatedVariables, ...calculations };
if (jumpTarget && !firstJumpTarget) {
firstJumpTarget = jumpTarget;
}
}
}
}
// If no jump target was set, check for a fallback logic
if (!firstJumpTarget && currentBlock?.logicFallback) {
firstJumpTarget = currentBlock.logicFallback;
}
// Return the first jump target if found, otherwise go to the next element
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
return { nextElementId, updatedSurvey, updatedVariables };
}; };
export const getSurveySummaryDropOff = ( export const getSurveySummaryDropOff = (
@@ -174,16 +118,8 @@ export const getSurveySummaryDropOff = (
let impressionsArr = new Array(elements.length).fill(0) as number[]; let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[]; let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const surveyVariablesData = survey.variables?.reduce(
(acc, variable) => {
acc[variable.id] = variable.value;
return acc;
},
{} as Record<string, string | number>
);
responses.forEach((response) => { responses.forEach((response) => {
// Calculate total time-to-completion // Calculate total time-to-completion per element
Object.keys(totalTtc).forEach((elementId) => { Object.keys(totalTtc).forEach((elementId) => {
if (response.ttc && response.ttc[elementId]) { if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId]; totalTtc[elementId] += response.ttc[elementId];
@@ -191,51 +127,21 @@ export const getSurveySummaryDropOff = (
} }
}); });
let localSurvey = structuredClone(survey); // Count impressions based on actual interaction data (ttc + response data)
let localResponseData: TResponseData = { ...response.data }; // instead of replaying survey logic which is unreliable with branching
let localVariables: TResponseVariables = { let lastSeenIdx = -1;
...surveyVariablesData,
};
let currQuesIdx = 0; for (let i = 0; i < elements.length; i++) {
const element = elements[i];
while (currQuesIdx < elements.length) { if (wasElementSeen(response, element.id)) {
const currQues = elements[currQuesIdx]; impressionsArr[i]++;
if (!currQues) break; lastSeenIdx = i;
// element is not answered and required
if (response.data[currQues.id] === undefined && currQues.required) {
dropOffArr[currQuesIdx]++;
impressionsArr[currQuesIdx]++;
break;
} }
}
impressionsArr[currQuesIdx]++; // Attribute drop-off to the last element the respondent interacted with
if (!response.finished && lastSeenIdx >= 0) {
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId( dropOffArr[lastSeenIdx]++;
localSurvey,
elements,
localResponseData,
localVariables,
currQuesIdx,
currQues,
response.language
);
localSurvey = updatedSurvey;
localVariables = updatedVariables;
if (nextElementId) {
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
if (!response.data[nextElementId] && !response.finished) {
dropOffArr[nextQuesIdx]++;
impressionsArr[nextQuesIdx]++;
break;
}
currQuesIdx = nextQuesIdx;
} else {
currQuesIdx++;
}
} }
}); });
@@ -244,6 +150,8 @@ export const getSurveySummaryDropOff = (
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0; totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
}); });
// When the welcome card is disabled, the first element's impressions should equal displayCount
// because every survey display is an impression of the first element
if (!survey.welcomeCard.enabled) { if (!survey.welcomeCard.enabled) {
dropOffArr[0] = displayCount - impressionsArr[0]; dropOffArr[0] = displayCount - impressionsArr[0];
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0; if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
@@ -255,7 +163,7 @@ export const getSurveySummaryDropOff = (
impressionsArr[0] = displayCount; impressionsArr[0] = displayCount;
} else { } else {
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100; dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0;
} }
for (let i = 1; i < elements.length; i++) { for (let i = 1; i < elements.length; i++) {
@@ -293,7 +201,10 @@ const checkForI18n = (
) => { ) => {
const element = elements.find((element) => element.id === id); const element = elements.find((element) => element.id === id);
if (element?.type === "multipleChoiceMulti" || element?.type === "ranking") { if (
element?.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
element?.type === TSurveyElementTypeEnum.Ranking
) {
// Initialize an array to hold the choice values // Initialize an array to hold the choice values
let choiceValues = [] as string[]; let choiceValues = [] as string[];
@@ -318,13 +229,9 @@ const checkForI18n = (
} }
// Return the localized value of the choice fo multiSelect single element // Return the localized value of the choice fo multiSelect single element
if (element && "choices" in element) { if (element?.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
const choice = element.choices?.find( const choice = element.choices?.find((choice) => choice.label[languageCode] === responseData[id]);
(choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id] return choice ? getLocalizedValue(choice.label, "default") || responseData[id] : responseData[id];
);
return choice && "label" in choice
? getLocalizedValue(choice.label, "default") || responseData[id]
: responseData[id];
} }
return responseData[id]; return responseData[id];
@@ -832,13 +739,19 @@ export const getElementSummary = async (
let totalResponseCount = 0; let totalResponseCount = 0;
// Initialize count object // Initialize count object
const countMap: Record<string, string> = rows.reduce((acc, row) => { const countMap: Record<string, Record<string, number>> = rows.reduce(
acc[row] = columns.reduce((colAcc, col) => { (acc: Record<string, Record<string, number>>, row) => {
colAcc[col] = 0; acc[row] = columns.reduce(
return colAcc; (colAcc: Record<string, number>, col) => {
}, {}); colAcc[col] = 0;
return acc; return colAcc;
}, {}); },
{} as Record<string, number>
);
return acc;
},
{} as Record<string, Record<string, number>>
);
responses.forEach((response) => { responses.forEach((response) => {
const selectedResponses = response.data[element.id] as Record<string, string>; const selectedResponses = response.data[element.id] as Record<string, string>;
@@ -1095,7 +1008,7 @@ export const getResponsesForSummary = reactCache(
[limit, ZOptionalNumber], [limit, ZOptionalNumber],
[offset, ZOptionalNumber], [offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()], [filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.string().cuid2().optional()] [cursor, z.cuid2().optional()]
); );
const queryLimit = limit ?? RESPONSES_PER_PAGE; const queryLimit = limit ?? RESPONSES_PER_PAGE;
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation"; import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage"; import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -32,26 +33,27 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const survey = await getSurvey(params.surveyId); const survey = await getSurvey(params.surveyId);
if (!survey) { if (!survey) {
throw new Error(t("common.survey_not_found")); throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
} }
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
const isContactsEnabled = await getIsContactsEnabled(); const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
const segments = isContactsEnabled ? await getSegments(environment.id) : []; const segments = isContactsEnabled ? await getSegments(environment.id) : [];
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) { if (!organizationId) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), null);
} }
const organizationBilling = await getOrganizationBilling(organizationId); const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) { if (!organizationBilling) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), organizationId);
} }
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan); const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration // Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId); const initialSurveySummary = await getSurveySummary(surveyId);
@@ -2,23 +2,16 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses"; import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service"; import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas"; import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey"; import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZGetResponsesDownloadUrlAction = z.object({ const ZGetResponsesDownloadUrlAction = z.object({
@@ -28,7 +21,7 @@ const ZGetResponsesDownloadUrlAction = z.object({
}); });
export const getResponsesDownloadUrlAction = authenticatedActionClient export const getResponsesDownloadUrlAction = authenticatedActionClient
.schema(ZGetResponsesDownloadUrlAction) .inputSchema(ZGetResponsesDownloadUrlAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -58,7 +51,7 @@ const ZGetSurveyFilterDataAction = z.object({
}); });
export const getSurveyFilterDataAction = authenticatedActionClient export const getSurveyFilterDataAction = authenticatedActionClient
.schema(ZGetSurveyFilterDataAction) .inputSchema(ZGetSurveyFilterDataAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const survey = await getSurvey(parsedInput.surveyId); const survey = await getSurvey(parsedInput.surveyId);
@@ -89,7 +82,7 @@ export const getSurveyFilterDataAction = authenticatedActionClient
throw new ResourceNotFoundError("Organization", organizationId); throw new ResourceNotFoundError("Organization", organizationId);
} }
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan); const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([ const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
getTagsByEnvironmentId(survey.environmentId), getTagsByEnvironmentId(survey.environmentId),
@@ -99,76 +92,3 @@ export const getSurveyFilterDataAction = authenticatedActionClient
return { environmentTags: tags, attributes, meta, hiddenFields, quotas }; return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
}); });
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param {string} organizationId The ID of the organization to check.
* @returns {Promise<void>} A promise that resolves if the permission is granted.
* @throws {ResourceNotFoundError} If the organization is not found.
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging(
"updated",
"survey",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
}
)
);
@@ -164,12 +164,12 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const datePickerRef = useRef<HTMLDivElement>(null); const datePickerRef = useRef<HTMLDivElement>(null);
const extractMetadataKeys = useCallback((obj, parentKey = "") => { const extractMetadataKeys = useCallback((obj: Record<string, unknown>, parentKey = "") => {
let keys: string[] = []; let keys: string[] = [];
for (let key in obj) { for (let key in obj) {
if (typeof obj[key] === "object" && obj[key] !== null) { if (typeof obj[key] === "object" && obj[key] !== null) {
keys = keys.concat(extractMetadataKeys(obj[key], parentKey + key + " - ")); keys = keys.concat(extractMetadataKeys(obj[key] as Record<string, unknown>, parentKey + key + " - "));
} else { } else {
keys.push(parentKey + key); keys.push(parentKey + key);
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import clsx from "clsx"; import clsx from "clsx";
import { TFunction } from "i18next";
import { import {
AirplayIcon, AirplayIcon,
ArrowUpFromDotIcon, ArrowUpFromDotIcon,
@@ -54,6 +55,25 @@ export enum OptionsType {
QUOTAS = "Quotas", QUOTAS = "Quotas",
} }
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
switch (type) {
case OptionsType.ELEMENTS:
return t("common.elements");
case OptionsType.TAGS:
return t("common.tags");
case OptionsType.ATTRIBUTES:
return t("common.attributes");
case OptionsType.OTHERS:
return t("common.other_filters");
case OptionsType.META:
return t("common.meta");
case OptionsType.HIDDEN_FIELDS:
return t("common.hidden_fields");
case OptionsType.QUOTAS:
return t("common.quotas");
}
};
export type ElementOption = { export type ElementOption = {
label: string; label: string;
elementType?: TSurveyElementTypeEnum; elementType?: TSurveyElementTypeEnum;
@@ -113,7 +133,9 @@ const elementIcons = {
}; };
const getIcon = (type: string) => { const getIcon = (type: string) => {
const IconComponent = elementIcons[type]; const IconComponent = (elementIcons as Record<string, (typeof elementIcons)[keyof typeof elementIcons]>)[
type
];
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null; return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
}; };
@@ -192,7 +214,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
value={inputValue} value={inputValue}
onValueChange={setInputValue} onValueChange={setInputValue}
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")} placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none" className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
/> />
)} )}
<Button <Button
@@ -216,7 +238,12 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
{options?.map((data) => ( {options?.map((data) => (
<Fragment key={data.header}> <Fragment key={data.header}>
{data?.option.length > 0 && ( {data?.option.length > 0 && (
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}> <CommandGroup
heading={
<p className="text-sm font-medium text-slate-600">
{getOptionsTypeTranslationKey(data.header, t)}
</p>
}>
{data?.option?.map((o) => ( {data?.option?.map((o) => (
<CommandItem <CommandItem
key={o.id} key={o.id}
@@ -198,7 +198,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
}; };
setFilterValue({ ...filterValue }); setFilterValue({ ...filterValue });
}; };
const handleRemoveMultiSelect = (value: string[], index) => { const handleRemoveMultiSelect = (value: string[], index: number) => {
filterValue.filter[index] = { filterValue.filter[index] = {
...filterValue.filter[index], ...filterValue.filter[index],
filterType: { filterType: {
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -14,7 +15,6 @@ import {
SelectValue, SelectValue,
} from "@/modules/ui/components/select"; } from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator"; import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { updateSurveyAction } from "../actions";
interface SurveyStatusDropdownProps { interface SurveyStatusDropdownProps {
environment: TEnvironment; environment: TEnvironment;
@@ -34,23 +34,27 @@ export const SurveyStatusDropdown = ({
const updateSurveyActionResponse = await updateSurveyAction({ ...survey, status }); const updateSurveyActionResponse = await updateSurveyAction({ ...survey, status });
if (updateSurveyActionResponse?.data) { if (updateSurveyActionResponse?.data) {
toast.success( const resultingStatus = updateSurveyActionResponse.data.status;
status === "inProgress" const statusToToastMessage: Partial<Record<TSurvey["status"], string>> = {
? t("common.survey_live") inProgress: t("common.survey_live"),
: status === "paused" paused: t("common.survey_paused"),
? t("common.survey_paused") completed: t("common.survey_completed"),
: status === "completed" };
? t("common.survey_completed")
: "" const toastMessage = statusToToastMessage[resultingStatus];
); if (toastMessage) {
toast.success(toastMessage);
}
if (updateLocalSurveyStatus) {
updateLocalSurveyStatus(resultingStatus);
}
router.refresh(); router.refresh();
} else { } else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse); const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
toast.error(errorMessage); toast.error(errorMessage);
} }
if (updateLocalSurveyStatus) updateLocalSurveyStatus(status);
}; };
return ( return (
@@ -1,4 +1,6 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { SurveyContextWrapper } from "./context/survey-context"; import { SurveyContextWrapper } from "./context/survey-context";
interface SurveyLayoutProps { interface SurveyLayoutProps {
@@ -10,9 +12,10 @@ const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => {
const resolvedParams = await params; const resolvedParams = await params;
const survey = await getSurvey(resolvedParams.surveyId); const survey = await getSurvey(resolvedParams.surveyId);
const t = await getTranslate();
if (!survey) { if (!survey) {
throw new Error("Survey not found"); throw new ResourceNotFoundError(t("common.survey"), resolvedParams.surveyId);
} }
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>; return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
const Page = async (props) => { const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params; const params = await props.params;
return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`); return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`);
}; };

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