Compare commits

..

156 Commits

Author SHA1 Message Date
Cursor Agent 893384a19d fix: load survey script directly via src instead of blob URL
Fixes FORMBRICKS-TQ

The blob URL approach was being blocked by CSP which doesn't include 'blob:'
in script-src directive. Simplified to load script directly via src attribute:
- Works with existing CSP ('self' is allowed)
- No need for fetch, blob URLs, or eval
- Properly executes and initializes window.formbricksSurveys
- Adds cache-busting in development for fresh script loads
2026-03-18 13:05:54 +00:00
Cursor Agent 1eba663294 chore: remove test files 2026-03-18 12:33:26 +00:00
Cursor Agent c37e5c0750 chore: add blob URL test results screenshot - all tests passing 2026-03-18 12:32:43 +00:00
Cursor Agent 75e47b4979 fix: use blob URL to execute survey script and initialize window.formbricksSurveys
Fixes FORMBRICKS-TQ

The script content was being assigned to textContent which may not execute
properly in all contexts. Changed to use a Blob URL approach which:
- Creates a blob from the fetched script content
- Loads it via script src attribute (works with CSP without unsafe-eval)
- Properly waits for script execution before proceeding
- Ensures window.formbricksSurveys is initialized correctly
- Cleans up the blob URL after loading to prevent memory leaks
2026-03-18 12:31:17 +00:00
Cursor Agent 7504c47fc1 fix: execute survey script in global scope to initialize window.formbricksSurveys
Fixes FORMBRICKS-TQ

The script content was being assigned to textContent instead of being
executed, preventing the window.formbricksSurveys object from being
initialized. Changed to use indirect eval pattern to execute the script
content in the global scope, ensuring proper initialization.
2026-03-18 12:22:53 +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
Johannes 3776b31794 feat: add impressions tab and display data retrieval for surveys (#7266)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-24 11:00:58 +00:00
Bhagya Amarasinghe 5c7ea33fb0 feat: add pod disruption budget for helm chart (#7339) 2026-02-24 10:43:16 +00:00
Balázs Úr 33f60ce2be fix: button label on create attribute dialog (#7331)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-24 08:30:20 +00:00
Bhagya Amarasinghe c0386cea5a perf(contacts): batch segment evaluation queries into single transaction (#7333)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 08:26:46 +00:00
Anshuman Pandey 7cea53130c chore: adds webhook signing to test event (#7320) 2026-02-23 12:36:50 +00:00
Dhruwang Jariwala 0636989d67 fix: update test configuration to exclude .next directory from testing (#7334) 2026-02-23 11:33:17 +01:00
Anshuman Pandey 219883266c fix: add bool support (#7323) 2026-02-20 15:30:40 +00:00
Theodór Tómas 55fc2b2bc8 chore: removing i18n from pre-commit hook (#7318) 2026-02-20 10:48:44 +00:00
neila 6e4ef9a099 fix: make pretty URL paths accessible from public domain (#7264)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-20 09:55:40 +00:00
Chowdhury Tafsir Ahmed Siddiki ebf7d1e3a1 fix: prevent crash in NotificationSwitch via optional chaining (#7268)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-20 09:55:06 +00:00
Dhruwang Jariwala 998162bc48 fix: Google Sheets integration — token expiry & permission error handling (#7282) (#7285) 2026-02-20 08:56:24 +00:00
Anshuman Pandey 4fadc54b4e fix: fixes storage resolution issues (#7310) 2026-02-19 14:03:19 +00:00
Dhruwang Jariwala f4ac9a8292 fix: always validate only responseData fields in client/management APIs (#7292) (#7296)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 08:56:42 +00:00
Anshuman Pandey 7c8a7606b7 fix: fixes the no segment in draft surveys bug (#7290) 2026-02-19 08:16:18 +00:00
Anshuman Pandey 225217330b fix: adds dataType filter in bc code (#7294) 2026-02-19 07:47:58 +00:00
Dhruwang Jariwala 589c04a530 fix: allow CTA elements to proceed when marked required (#1415) (#7293)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 06:56:03 +00:00
Anshuman Pandey aa538a3a51 fix: better query in the backwards compatible code (#7288) 2026-02-18 13:00:19 +00:00
Anshuman Pandey 817e108ff5 docs: adds migration docs (#7281)
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
2026-02-17 17:01:46 +01:00
Theodór Tómas 33542d0c54 fix: default preview colors (#7277)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-17 11:28:58 +00:00
Matti Nannt f37d22f13d docs: align rate limiting docs with current code enforcement (#7267)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-17 07:42:53 +00:00
Anshuman Pandey 202ae903ac chore: makes rate limit config const (#7274) 2026-02-17 06:49:56 +00:00
Dhruwang Jariwala 6ab5cc367c fix: reduced default height of input (#7259) 2026-02-17 05:11:29 +00:00
Theodór Tómas 21559045ba fix: input placeholder color (#7265) 2026-02-17 05:11:01 +00:00
Theodór Tómas d7c57a7a48 fix: disabling cache in dev (#7269) 2026-02-17 04:44:22 +00:00
Chowdhury Tafsir Ahmed Siddiki 11b2ef4788 docs: remove stale 'coming soon' placeholders (#7254) 2026-02-16 13:21:12 +00:00
Theodór Tómas 6fefd51cce fix: suggest colors has better succes copy (#7258) 2026-02-16 13:18:46 +00:00
Theodór Tómas 65af826222 fix: matrix table preview (#7257)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-16 13:18:17 +00:00
Anshuman Pandey 12eb54c653 fix: fixes number being passed into string attribute (#7255) 2026-02-16 11:18:59 +00:00
Dhruwang Jariwala 5aa1427e64 fix: input combobx height (#7256) 2026-02-16 10:03:23 +00:00
Bhagya Amarasinghe 08ac490512 fix: pino transport target resolution (#7252)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-02-13 14:57:08 +00:00
Bhagya Amarasinghe 4538c7bbcb fix: remove custom level formatter when using pino multi-transport (#7251)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 12:46:58 +00:00
Bhagya Amarasinghe 7495c04048 fix: update Helm chart path in release workflow from helm-chart/ to charts/formbricks/ (#7250) 2026-02-13 11:00:04 +00:00
Matti Nannt 85a1318f77 fix: force tar 7.5.7 to resolve Dependabot alerts #249/#264 (#7248) 2026-02-13 10:06:58 +00:00
Dhruwang Jariwala 22ae0a731e fix: add auth checks to OAuth integration callbacks (#1338) (#7247)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 09:44:55 +00:00
Anshuman Pandey f7e8bc1630 feat: attributes data types (#7246)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-02-13 08:55:06 +00:00
Dhruwang Jariwala 36f091bc73 chore: removed i18n-utils dependency from surveys package (#7223)
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-13 08:08:18 +00:00
Balázs Úr 091b78d1e3 fix: Hungarian translations (#7241)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-13 05:40:57 +00:00
Bhagya Amarasinghe 18a7b233f0 fix: distributed lock for license fetch when Redis cache is cold (#7225)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 21:01:21 +00:00
Bhagya Amarasinghe b52627b3e9 feat: integrate OpenTelemetry for enhanced monitoring and tracing (#7235)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 11:33:52 +00:00
Dhruwang Jariwala 73e8e2f899 feat: license status for self hosters (#7236) 2026-02-12 08:41:00 +00:00
Dhruwang Jariwala fb0ef2fa82 chore: 7114 improve ux in team settings (#7237)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-02-12 06:18:05 +00:00
Dhruwang Jariwala 8ab8adc3d0 fix: onboarding preview (#7238) 2026-02-11 14:46:23 +00:00
Bhagya Amarasinghe fad55e3486 feat: add behavior configuration for autoscaling in values.yaml (#7239) 2026-02-11 13:13:20 +00:00
Theodór Tómas a5c92bbc7b fix: prevent expected auth errors from being reported to Sentry (#7215)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-11 08:43:08 +00:00
Theodór Tómas 48eff5b547 feat: advance css vars (#7135)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-10 17:34:25 +00:00
Anshuman Pandey ff10ca7d6a fix: allows local ip images (#7189)
Co-authored-by: pandeymangg <pandeyman@Anshumans-MacBook-Air.local>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-02-10 17:29:27 +01:00
Theodór Tómas 04c2b030f1 chore: inject rules in agents-md (#7203)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-10 13:36:44 +00:00
Dhruwang Jariwala 256b223925 fix: update welcome card toggle logic to set active element when enabled (#7230) 2026-02-10 08:21:37 +00:00
Dhruwang Jariwala f3ff4c9951 fix: added next-env.d.ts to gitignore (#7220) 2026-02-10 08:21:15 +00:00
Dhruwang Jariwala 2a590ef315 chore: improved action searching (#7234) 2026-02-10 08:19:24 +00:00
Dhruwang Jariwala 07a6cd6c0e chore: survey ui console warnings (#7228) 2026-02-09 07:39:30 +00:00
Dhruwang Jariwala 335da2f1f5 fix: webhook data not being sent (#7219) 2026-02-09 06:06:30 +00:00
bharath kumar 13b9db915b fix(js-core): invert expiration logic for SDK error state (#7190) (#7202)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-09 05:08:19 +00:00
AndresAIFR 76b25476b3 fix: check serverError before showing success toast (#7185)
Co-authored-by: Andres Cruciani <AndresAIFR@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-09 04:49:36 +00:00
Dhruwang Jariwala 04220902b4 fix: external links are not working in picture selection question and ending card (#7221) 2026-02-06 18:08:00 +00:00
Theodór Tómas 4649a2de3e fix: fixing issue with saving follow ups (#7218)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-06 10:42:35 +00:00
Dhruwang Jariwala 56ce05fb94 fix: validation in client api (#7206)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-06 06:55:41 +00:00
Anshuman Pandey 1b81e68106 feat: overlay close (#7197) 2026-02-06 06:08:19 +00:00
Theodór Tómas 202958cac2 fix: replace @vercel/og with next/og (#7208) 2026-02-06 04:53:42 +00:00
Harsh Bhat 8e901fb3c9 docs: Validation Rules (#7213) 2026-02-05 14:51:26 +00:00
Harsh Bhat 29afb3e4e9 docs: Formbricks Hubspot integration (#7212) 2026-02-05 12:30:05 +00:00
Matti Nannt 38a3b31761 fix: upgrade preact to fix JSON VNode Injection vulnerability (#7209)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 11:10:12 +00:00
Dhruwang Jariwala 2bfb79d999 fix: translation github action (#7207) 2026-02-05 11:06:21 +00:00
Matti Nannt 7971b9b312 fix(security): upgrade pnpm and AWS SDK to fix vulnerabilities (#7192)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 13:29:17 +00:00
Johannes 1143f58ba5 fix: refresh invite expiration when sharing link (#7198)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-04 13:28:25 +00:00
Balázs Úr 47fe3c73dd fix: Hungarian translations (#7199)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-04 13:26:27 +00:00
Dhruwang Jariwala 727e586b16 feat: responseID in response table (#7195) 2026-02-04 09:59:37 +00:00
Theodór Tómas 4a9b4d52ca fix: resolve infinite re-render loop in Survey Editor (#7142) 2026-02-04 05:03:09 +00:00
Sadiq Mohammed cbb0166419 chore(docker): add healthchecks and wait for postgres and redis readiness (#7121) 2026-02-03 13:37:53 +00:00
Balázs Úr 4b0c518683 chore: use Unicode punctuation, remove contractions, make wording consistent (#7049)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-03 10:37:49 +00:00
1177 changed files with 50709 additions and 26992 deletions
+8 -2
View File
@@ -150,6 +150,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,8 +185,13 @@ 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
# OpenTelemetry URL for tracing # OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OPENTELEMETRY_LISTENER_URL=http://localhost:4318/v1/traces # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# OTEL_SERVICE_NAME=formbricks
# OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development
# OTEL_TRACES_SAMPLER=parentbased_traceidratio
# OTEL_TRACES_SAMPLER_ARG=1
# Unsplash API Key # Unsplash API Key
UNSPLASH_ACCESS_KEY= UNSPLASH_ACCESS_KEY=
@@ -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 }}
+3 -3
View File
@@ -65,8 +65,8 @@ jobs:
set -euo pipefail set -euo pipefail
echo "Updating Chart.yaml with version: ${VERSION}" echo "Updating Chart.yaml with version: ${VERSION}"
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml yq -i ".version = \"${VERSION}\"" charts/formbricks/Chart.yaml
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
echo "✅ Successfully updated Chart.yaml" echo "✅ Successfully updated Chart.yaml"
@@ -77,7 +77,7 @@ jobs:
set -euo pipefail set -euo pipefail
echo "Packaging Helm chart version: ${VERSION}" echo "Packaging Helm chart version: ${VERSION}"
helm package ./helm-chart helm package ./charts/formbricks
echo "✅ Successfully packaged formbricks-${VERSION}.tgz" echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
+4
View File
@@ -9,6 +9,7 @@ on:
merge_group: merge_group:
permissions: permissions:
contents: read contents: read
pull-requests: read
jobs: jobs:
sonarqube: sonarqube:
name: SonarQube name: SonarQube
@@ -50,6 +51,9 @@ jobs:
pnpm test:coverage pnpm test:coverage
- name: SonarQube Scan - name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
with:
args: >
-Dsonar.verbose=true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+27 -30
View File
@@ -6,19 +6,9 @@ permissions:
on: on:
pull_request: pull_request:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
push: push:
branches: branches:
- main - main
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
jobs: jobs:
validate-translations: validate-translations:
@@ -32,32 +22,39 @@ jobs:
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js - name: Check for relevant changes
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 id: changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with: with:
node-version: 18 filters: |
translations:
- 'apps/web/**/*.ts'
- 'apps/web/**/*.tsx'
- 'apps/web/locales/**/*.json'
- 'packages/surveys/src/**/*.{ts,tsx}'
- 'packages/surveys/locales/**/*.json'
- 'packages/email/**/*.{ts,tsx}'
- name: Setup pnpm - name: Setup Node.js 22.x
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with: with:
version: 9.15.9 node-version: 22.x
- name: Install pnpm
if: steps.changes.outputs.translations == 'true'
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys - name: Validate translation keys
run: | if: steps.changes.outputs.translations == 'true'
echo "" run: pnpm run scan-translations
echo "🔍 Validating translation keys..."
echo ""
pnpm run scan-translations
- name: Summary - name: Skip (no translation-related changes)
if: success() if: steps.changes.outputs.translations != 'true'
run: | run: echo "No translation-related files changed — skipping validation."
echo ""
echo "✅ Translation validation completed successfully!"
echo ""
+3
View File
@@ -13,6 +13,7 @@
**/.next/ **/.next/
**/out/ **/out/
**/build **/build
**/next-env.d.ts
# node # node
**/dist/ **/dist/
@@ -63,3 +64,5 @@ packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdat
.cursorrules .cursorrules
i18n.cache i18n.cache
stats.html stats.html
# next-agents-md
.next-docs/
-2
View File
@@ -1,2 +0,0 @@
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
prettier --write ./branch.json
-39
View File
@@ -1,40 +1 @@
# Load environment variables from .env files
if [ -f .env ]; then
set -a
. .env
set +a
fi
pnpm lint-staged pnpm lint-staged
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
if [ -n "$LINGODOTDEV_API_KEY" ]; then
echo ""
echo "🌍 Running Lingo.dev translation workflow..."
echo ""
# Run translation generation and validation
if pnpm run i18n; then
echo ""
echo "✅ Translation validation passed"
echo ""
# Add updated locale files to git
git add apps/web/locales/*.json
else
echo ""
echo "❌ Translation validation failed!"
echo ""
echo "Please fix the translation issues above before committing:"
echo " • Add missing translation keys to your locale files"
echo " • Remove unused translation keys"
echo ""
echo "Or run 'pnpm i18n' to see the detailed report"
echo ""
exit 1
fi
else
echo ""
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
echo " (This is expected for community contributors)"
echo ""
fi
+15
View File
File diff suppressed because one or more lines are too long
+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"
} }
} }
-16
View File
@@ -1,20 +1,4 @@
module.exports = { module.exports = {
extends: ["@formbricks/eslint-config/legacy-next.js"], extends: ["@formbricks/eslint-config/legacy-next.js"],
ignorePatterns: ["**/package.json", "**/tsconfig.json"], ignorePatterns: ["**/package.json", "**/tsconfig.json"],
overrides: [
{
files: ["locales/*.json"],
plugins: ["i18n-json"],
rules: {
"i18n-json/identical-keys": [
"error",
{
filePath: require("path").join(__dirname, "locales", "en-US.json"),
checkExtraKeys: false,
checkMissingKeys: true,
},
],
},
},
],
}; };
+6
View File
@@ -0,0 +1,6 @@
const baseConfig = require("../../.prettierrc.js");
module.exports = {
...baseConfig,
tailwindConfig: "./tailwind.config.js",
};
+36 -20
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine3.22 AS base FROM node:24-alpine3.23 AS base
# #
## step 1: Prune monorepo ## step 1: Prune monorepo
@@ -20,7 +20,7 @@ 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@9.15.9 --activate RUN corepack prepare pnpm@10.28.2 --activate
# Install necessary build tools and compilers # Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3 RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
@@ -67,22 +67,18 @@ 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...
# Extract Prisma version
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
# #
## step 3: setup production runner ## step 3: setup production runner
# #
FROM base AS runner FROM base AS runner
RUN npm install --ignore-scripts -g corepack@latest && \ # Upgrade Alpine system packages to pick up security patches, update npm to latest, then create user
corepack enable # Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
RUN apk update && apk upgrade --no-cache \
RUN apk add --no-cache curl \ && npm install --ignore-scripts -g npm@latest \
&& apk add --no-cache supercronic \
# && addgroup --system --gid 1001 nodejs \
&& addgroup -S nextjs \ && addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs && adduser -S -u 1001 -G nextjs nextjs
@@ -107,26 +103,30 @@ 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
COPY --from=installer /app/packages/database/dist ./packages/database/dist COPY --from=installer /app/packages/database/dist ./packages/database/dist
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
# Copy prisma client packages
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
COPY --from=installer /prisma_version.txt .
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
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
@@ -134,7 +134,25 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod RUN chmod -R 755 ./node_modules/zod
RUN npm install -g prisma@6 # Pino loads transport code in worker threads via dynamic require().
# Next.js file tracing only traces static imports, missing runtime-loaded files
# (e.g. pino/lib/transport-stream.js, transport targets).
# Copy the full packages to ensure all runtime files are available.
COPY --from=installer /app/node_modules/pino ./node_modules/pino
RUN chmod -R 755 ./node_modules/pino
COPY --from=installer /app/node_modules/pino-opentelemetry-transport ./node_modules/pino-opentelemetry-transport
RUN chmod -R 755 ./node_modules/pino-opentelemetry-transport
COPY --from=installer /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
RUN chmod -R 755 ./node_modules/pino-abstract-transport
COPY --from=installer /app/node_modules/otlp-logger ./node_modules/otlp-logger
RUN chmod -R 755 ./node_modules/otlp-logger
# Install prisma CLI globally for database migrations and fix permissions for nextjs user
RUN npm install --ignore-scripts -g prisma@6 \
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
# Create a startup script to handle the conditional logic # Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
@@ -144,10 +162,8 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
USER nextjs USER nextjs
# Prepare pnpm as the nextjs user to ensure it's available at runtime
# Prepare volumes for uploads and SAML connections # Prepare volumes for uploads and SAML connections
RUN corepack prepare pnpm@9.15.9 --activate && \ RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/uploads/ VOLUME /home/nextjs/apps/web/uploads/
@@ -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">
@@ -46,7 +46,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";
@@ -25,7 +26,7 @@ const mockProject: TProject = {
}, },
placement: "bottomRight", placement: "bottomRight",
clickOutsideClose: true, clickOutsideClose: true,
darkOverlay: false, overlay: "none",
environments: [], environments: [],
languages: [], languages: [],
logo: null, logo: null,
@@ -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);
@@ -49,7 +49,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();
@@ -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;
@@ -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={"/"}>
@@ -8,7 +8,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;
@@ -28,8 +31,10 @@ const OnboardingLayout = async (props) => {
throw new Error(t("common.organization_not_found")); throw new Error(t("common.organization_not_found"));
} }
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;
@@ -3,7 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -17,6 +17,7 @@ import {
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions"; import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
import { previewSurvey } from "@/app/lib/templates"; import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage"; import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team"; import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal"; import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
@@ -64,10 +65,17 @@ export const ProjectSettings = ({
const { t } = useTranslation(); const { t } = useTranslation();
const addProject = async (data: TProjectUpdateInput) => { const addProject = async (data: TProjectUpdateInput) => {
try { try {
// Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted.
// Without this, only brandColor is saved and the look-and-feel page falls
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createProjectResponse = await createProjectAction({ const createProjectResponse = await createProjectAction({
organizationId, organizationId,
data: { data: {
...data, ...data,
styling: fullStyling,
config: { channel, industry }, config: { channel, industry },
teamIds: data.teamIds, teamIds: data.teamIds,
}, },
@@ -112,6 +120,7 @@ export const ProjectSettings = ({
const projectName = form.watch("name"); const projectName = form.watch("name");
const logoUrl = form.watch("logo.url"); const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor; const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
const { isSubmitting } = form.formState; const { isSubmitting } = form.formState;
const organizationTeamsOptions = organizationTeams.map((team) => ({ const organizationTeamsOptions = organizationTeams.map((team) => ({
@@ -219,30 +228,28 @@ 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}
alt="Logo" alt="Logo"
width={256} width={256}
height={56} height={56}
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1" className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/> />
)} )}
<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 || "my Product", t)} survey={previewSurvey(projectName || t("common.my_product"), t)}
styling={{ brandColor: { light: brandColor } }} 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}
setOpen={setCreateTeamModalOpen} setOpen={setCreateTeamModalOpen}
@@ -42,7 +42,7 @@ 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 Error(t("common.organization_teams_not_found"));
@@ -69,7 +69,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(),
}); });
@@ -2,7 +2,10 @@ import { redirect } from "next/navigation";
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;
@@ -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,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 { AuthorizationError, OperationNotAllowedError } 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 +10,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,11 +24,8 @@ 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",
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx; const { user } = ctx;
const organizationId = parsedInput.organizationId; const organizationId = parsedInput.organizationId;
@@ -53,7 +49,7 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
throw new Error("Organization not found"); throw new Error("Organization not found");
} }
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits); const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id); const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) { if (organizationProjectsCount >= organizationProjectsLimit) {
@@ -61,7 +57,7 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
} }
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) { if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan); const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!isAccessControlAllowed) { if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles"); throw new OperationNotAllowedError("You do not have permission to manage roles");
@@ -84,8 +80,7 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
ctx.auditLoggingCtx.projectId = project.id; ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project; ctx.auditLoggingCtx.newObject = project;
return project; return project;
} })
)
); );
const ZGetOrganizationsForSwitcherAction = z.object({ const ZGetOrganizationsForSwitcherAction = z.object({
@@ -97,7 +92,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 +117,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,
@@ -138,7 +133,7 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
// Need membership for getProjectsByUserId (1 DB query) // Need membership for getProjectsByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId); const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) { if (!membership) {
throw new Error("Membership not found"); throw new AuthorizationError("Membership not found");
} }
return await getProjectsByUserId(ctx.user.id, membership); return await getProjectsByUserId(ctx.user.id, membership);
@@ -29,7 +29,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isAccessControlAllowed, isAccessControlAllowed,
projectPermission, projectPermission,
license, license,
peopleCount,
responseCount, responseCount,
} = layoutData; } = layoutData;
@@ -38,7 +37,7 @@ 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
@@ -52,7 +51,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}
/> />
)} )}
@@ -11,6 +11,7 @@ import {
RocketIcon, RocketIcon,
UserCircleIcon, UserCircleIcon,
UserIcon, UserIcon,
WorkflowIcon,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -27,6 +28,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";
@@ -109,16 +111,26 @@ export const MainNavigation = ({
href: `/environments/${environment.id}/contacts`, href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"), name: t("common.contacts"),
icon: UserIcon, icon: UserIcon,
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"), isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
}, },
{ {
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, isFormbricksCloud]
); );
const dropdownNavigation = [ const dropdownNavigation = [
@@ -156,6 +168,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 (
@@ -185,7 +211,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} />
@@ -230,6 +256,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);
@@ -4,7 +4,10 @@ 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;
@@ -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,18 +11,9 @@ 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",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id); const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, { const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings, notificationSettings: parsedInput.notificationSettings,
@@ -32,6 +22,5 @@ export const updateNotificationSettingsAction = authenticatedActionClient
ctx.auditLoggingCtx.oldObject = oldObject; ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result; ctx.auditLoggingCtx.newObject = result;
return result; return result;
} })
)
); );
@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
const isChecked = const isChecked =
notificationType === "unsubscribedOrganizationIds" notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId) ? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true; : notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => { const handleSwitchChange = async () => {
setIsLoading(true); setIsLoading(true);
@@ -49,8 +49,11 @@ export const NotificationSwitch = ({
]; ];
} }
} else { } else {
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] = updatedNotificationSettings[notificationType] = {
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId]; ...updatedNotificationSettings[notificationType],
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
};
} }
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({ const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
@@ -78,7 +81,7 @@ export const NotificationSwitch = ({
) { ) {
switch (notificationType) { switch (notificationType) {
case "alert": case "alert":
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) { if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange(); handleSwitchChange();
toast.success( toast.success(
t( t(
@@ -16,8 +16,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 +26,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,7 +137,10 @@ 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();
@@ -20,7 +20,7 @@ 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 { sendForgotPasswordEmail, 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,17 +63,8 @@ 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",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
}) => {
const oldObject = await getUser(ctx.user.id); const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput); let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload }); payload = await handleEmailUpdate({ ctx, parsedInput, payload });
@@ -89,15 +80,11 @@ export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalIn
ctx.auditLoggingCtx.newObject = newObject; ctx.auditLoggingCtx.newObject = newObject;
return true; return true;
} })
)
); );
export const resetPasswordAction = authenticatedActionClient.action( export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging( withAuditLogging("passwordReset", "user", async ({ ctx }) => {
"passwordReset",
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") { if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user."); throw new OperationNotAllowedError("Password reset is not allowed for this user.");
} }
@@ -107,6 +94,5 @@ export const resetPasswordAction = authenticatedActionClient.action(
ctx.auditLoggingCtx.userId = ctx.user.id; ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true }; 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,7 +198,10 @@ export const EditProfileDetailsForm = ({
<FormField <FormField
control={form.control} control={form.control}
name="locale" name="locale"
render={({ field }) => ( render={({ field }) => {
const selectedLanguage = appLanguages.find((l) => l.code === field.value);
return (
<FormItem className="mt-4"> <FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel> <FormLabel>{t("common.language")}</FormLabel>
<FormControl> <FormControl>
@@ -209,7 +212,15 @@ export const EditProfileDetailsForm = ({
variant="ghost" variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left"> className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"} {selectedLanguage ? (
<>
{selectedLanguage.label["en-US"]}
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
` (${selectedLanguage.label.native})`}
</>
) : (
t("common.select")
)}
<ChevronDownIcon className="h-4 w-4 text-slate-500" /> <ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div> </div>
</Button> </Button>
@@ -218,12 +229,13 @@ export const EditProfileDetailsForm = ({
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700" className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start"> align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}> <DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => ( {sortedAppLanguages.map((lang) => (
<DropdownMenuRadioItem <DropdownMenuRadioItem
key={lang.code} key={lang.code}
value={lang.code} value={lang.code}
className="min-h-8 cursor-pointer"> className="min-h-8 cursor-pointer">
{lang.label["en-US"]} {lang.label["en-US"]}
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
))} ))}
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
@@ -232,7 +244,8 @@ export const EditProfileDetailsForm = ({
</FormControl> </FormControl>
<FormError /> <FormError />
</FormItem> </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)}
/> />
@@ -60,7 +60,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`
@@ -28,7 +28,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
throw new Error(t("common.session_not_found")); throw new Error(t("common.session_not_found"));
} }
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>
);
};
@@ -0,0 +1,169 @@
"use client";
import { TFunction } from "i18next";
import { RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
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 { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { SettingsCard } from "../../../components/SettingsCard";
interface EnterpriseLicenseStatusProps {
status: TLicenseStatus;
lastChecked: Date;
gracePeriodEnd?: Date;
environmentId: string;
}
const getBadgeConfig = (
status: TLicenseStatus,
t: TFunction
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
switch (status) {
case "active":
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
case "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":
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
case "invalid_license":
return { type: "error", label: t("environments.settings.enterprise.license_status_invalid") };
default:
return { type: "gray", label: t("environments.settings.enterprise.license_status") };
}
};
export const EnterpriseLicenseStatus = ({
status,
lastChecked,
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
const { t } = useTranslation();
const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false);
const handleRecheck = async () => {
setIsRechecking(true);
try {
const result = await recheckLicenseAction({ environmentId });
if (result?.serverError) {
toast.error(result.serverError || t("environments.settings.enterprise.recheck_license_failed"));
return;
}
if (result?.data) {
if (result.data.status === "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") {
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
} else {
toast.success(t("environments.settings.enterprise.recheck_license_success"));
}
router.refresh();
} else {
toast.error(t("environments.settings.enterprise.recheck_license_failed"));
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("environments.settings.enterprise.recheck_license_failed")
);
} finally {
setIsRechecking(false);
}
};
const badgeConfig = getBadgeConfig(status, t);
return (
<SettingsCard
title={t("environments.settings.enterprise.license_status")}
description={t("environments.settings.enterprise.license_status_description")}>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5">
<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")}{" "}
{new Date(lastChecked).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
</span>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRecheck}
disabled={isRechecking}
className="shrink-0">
{isRechecking ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4 animate-spin" />
{t("environments.settings.enterprise.rechecking")}
</>
) : (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
{t("environments.settings.enterprise.recheck_license")}
</>
)}
</Button>
</div>
{status === "unreachable" && gracePeriodEnd && (
<Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})}
</AlertDescription>
</Alert>
)}
{status === "invalid_license" && (
<Alert variant="error" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_invalid_description")}
</AlertDescription>
</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">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a
className="font-medium text-slate-700 underline hover:text-slate-900"
href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
</p>
</div>
</SettingsCard>
);
};
@@ -2,15 +2,17 @@ import { CheckIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; 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) {
@@ -25,7 +27,8 @@ const Page = async (props) => {
return notFound(); return notFound();
} }
const { active: isEnterpriseEdition } = await getEnterpriseLicense(); const licenseState = await getEnterpriseLicense();
const hasLicense = licenseState.status !== "no-license";
const paidFeatures = [ const paidFeatures = [
{ {
@@ -90,35 +93,26 @@ const Page = async (props) => {
activeId="enterprise" activeId="enterprise"
/> />
</PageHeader> </PageHeader>
{isEnterpriseEdition ? ( {hasLicense ? (
<div> <>
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-sm"> <EnterpriseLicenseStatus
<div className="space-y-4 p-8"> status={licenseState.status}
<div className="flex items-center gap-x-2"> lastChecked={licenseState.lastChecked}
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800"> gracePeriodEnd={
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" /> licenseState.status === "unreachable"
</div> ? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
<p className="text-slate-800"> : undefined
{t( }
"environments.settings.enterprise.your_enterprise_license_is_active_all_features_unlocked" environmentId={params.environmentId}
)} />
</p> {licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
</div> </>
<p className="text-sm text-slate-500">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a className="font-semibold underline" href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
</p>
</div>
</div>
</div>
) : ( ) : (
<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">
<svg <svg
viewBox="0 0 1024 1024" viewBox="0 0 1024 1024"
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0" className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true"> aria-hidden="true">
<circle <circle
cx={512} cx={512}
@@ -153,8 +147,8 @@ const Page = async (props) => {
{t("environments.settings.enterprise.enterprise_features")} {t("environments.settings.enterprise.enterprise_features")}
</h2> </h2>
<ul className="my-4 space-y-4"> <ul className="my-4 space-y-4">
{paidFeatures.map((feature, index) => ( {paidFeatures.map((feature) => (
<li key={index} className="flex items-center"> <li key={feature.title} className="flex items-center">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800"> <div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" /> <CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
</div> </div>
@@ -7,7 +7,6 @@ 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";
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 { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -17,18 +16,9 @@ const ZUpdateOrganizationNameAction = z.object({
}); });
export const updateOrganizationNameAction = authenticatedActionClient export const updateOrganizationNameAction = authenticatedActionClient
.schema(ZUpdateOrganizationNameAction) .inputSchema(ZUpdateOrganizationNameAction)
.action( .action(
withAuditLogging( withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId: parsedInput.organizationId, organizationId: parsedInput.organizationId,
@@ -47,19 +37,17 @@ export const updateOrganizationNameAction = authenticatedActionClient
ctx.auditLoggingCtx.oldObject = oldObject; ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result; ctx.auditLoggingCtx.newObject = result;
return result; return result;
} })
)
); );
const ZDeleteOrganizationAction = z.object({ 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 +65,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);
} })
) );
);
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions"; import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -32,7 +33,12 @@ export const DeleteOrganization = ({
setIsDeleting(true); setIsDeleting(true);
try { try {
await deleteOrganizationAction({ organizationId: organization.id }); const result = await deleteOrganizationAction({ organizationId: organization.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsDeleting(false);
return;
}
toast.success(t("environments.settings.general.organization_deleted_successfully")); toast.success(t("environments.settings.general.organization_deleted_successfully"));
if (typeof localStorage !== "undefined") { if (typeof localStorage !== "undefined") {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS); localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
@@ -101,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,6 +9,7 @@ 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 { DeleteOrganization } from "./components/DeleteOrganization"; import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"; import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
@@ -25,7 +26,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;
@@ -81,7 +82,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard> </SettingsCard>
)} )}
<div className="space-y-2">
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" /> <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>
); );
}; };
@@ -4,7 +4,7 @@ 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;
@@ -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`);
}; };
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ZResponseFilterCriteria } from "@formbricks/types/responses"; import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service"; import { getResponseCountBySurveyId, getResponses } from "@/lib/response/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";
@@ -22,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,
@@ -56,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,
@@ -84,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,
@@ -106,3 +107,31 @@ export const getResponseCountAction = authenticatedActionClient
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria); return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
}); });
const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId,
limit: z.int().min(1).max(100),
offset: z.int().nonnegative(),
});
export const getDisplaysWithContactAction = authenticatedActionClient
.inputSchema(ZGetDisplaysWithContactAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
return getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset);
});
@@ -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>;
}; };
@@ -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 },
}); });
@@ -5,7 +5,7 @@ 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 { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -41,7 +41,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">
@@ -232,7 +232,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,
@@ -316,6 +316,14 @@ export const generateResponseTableColumns = (
}, },
}; };
const responseIdColumn: ColumnDef<TResponseTableData> = {
accessorKey: "responseId",
header: () => <div className="gap-x-1.5">{t("common.response_id")}</div>,
cell: ({ row }) => {
return <IdBadge id={row.original.responseId} />;
},
};
const quotasColumn: ColumnDef<TResponseTableData> = { const quotasColumn: ColumnDef<TResponseTableData> = {
accessorKey: "quota", accessorKey: "quota",
header: t("common.quota"), header: t("common.quota"),
@@ -414,6 +422,7 @@ export const generateResponseTableColumns = (
const baseColumns = [ const baseColumns = [
personColumn, personColumn,
singleUseIdColumn, singleUseIdColumn,
responseIdColumn,
dateColumn, dateColumn,
...(showQuotasColumn ? [quotasColumn] : []), ...(showQuotasColumn ? [quotasColumn] : []),
statusColumn, statusColumn,
@@ -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];
}; };
@@ -17,7 +17,7 @@ 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();
@@ -27,7 +27,7 @@ const Page = async (props) => {
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(), findMatchingLocale(),
]); ]);
@@ -53,7 +53,7 @@ const Page = async (props) => {
throw new Error(t("common.organization_not_found")); throw new Error(t("common.organization_not_found"));
} }
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
@@ -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,24 +64,17 @@ 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({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId: parsedInput.organizationId, organizationId,
access: [ access: [
{ {
type: "organization", type: "organization",
@@ -91,12 +83,12 @@ export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAc
{ {
type: "projectTeam", type: "projectTeam",
minPermission: "readWrite", minPermission: "readWrite",
projectId: parsedInput.projectId, projectId,
}, },
], ],
}); });
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId; ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null; ctx.auditLoggingCtx.oldObject = null;
@@ -114,8 +106,7 @@ export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAc
deletedResponsesCount: deletedResponsesCount, deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount, 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">
@@ -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}
@@ -8,7 +8,7 @@ import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surv
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 { getOriginalFileNameFromUrl } from "@/modules/storage/utils"; import { getOriginalFileNameFromUrl } from "@/modules/storage/url-helpers";
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";
@@ -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>
@@ -0,0 +1,125 @@
"use client";
import { AlertCircleIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Button } from "@/modules/ui/components/button";
interface SummaryImpressionsProps {
displays: TDisplayWithContact[];
isLoading: boolean;
hasMore: boolean;
displaysError: string | null;
environmentId: string;
locale: TUserLocale;
onLoadMore: () => void;
onRetry: () => void;
}
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
if (!display.contact) return "";
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
};
export const SummaryImpressions = ({
displays,
isLoading,
hasMore,
displaysError,
environmentId,
locale,
onLoadMore,
onRetry,
}: SummaryImpressionsProps) => {
const { t } = useTranslation();
const renderContent = () => {
if (displaysError) {
return (
<div className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex items-center gap-2 text-red-600">
<AlertCircleIcon className="h-5 w-5" />
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
</div>
<p className="text-sm text-slate-500">{displaysError}</p>
<Button onClick={onRetry} variant="secondary" size="sm">
{t("common.try_again")}
</Button>
</div>
</div>
);
}
if (displays.length === 0) {
return (
<div className="p-8 text-center text-sm text-slate-500">
{t("environments.surveys.summary.no_identified_impressions")}
</div>
);
}
return (
<>
<div className="grid min-h-10 grid-cols-4 items-center border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-2 px-4 md:px-6">{t("common.user")}</div>
<div className="col-span-2 px-4 md:px-6">{t("environments.contacts.survey_viewed_at")}</div>
</div>
<div className="max-h-[62vh] overflow-y-auto">
{displays.map((display) => (
<div
key={display.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-xs text-slate-800 last:border-transparent md:text-sm">
<div className="col-span-2 pl-4 md:pl-6">
{display.contact ? (
<Link
className="ph-no-capture break-all text-slate-600 hover:underline"
href={`/environments/${environmentId}/contacts/${display.contact.id}`}>
{getDisplayContactIdentifier(display)}
</Link>
) : (
<span className="break-all text-slate-600">{t("common.anonymous")}</span>
)}
</div>
<div className="col-span-2 px-4 text-slate-500 md:px-6">
{timeSince(display.createdAt.toString(), locale)}
</div>
</div>
))}
</div>
{hasMore && (
<div className="flex justify-center border-t border-slate-100 py-4">
<Button onClick={onLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</>
);
};
if (isLoading) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
<div className="flex items-center justify-center">
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
);
}
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<InfoIcon className="h-4 w-4 shrink-0" />
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
</div>
{renderContent()}
</div>
);
};
@@ -10,12 +10,12 @@ interface SummaryMetadataProps {
surveySummary: TSurveySummary["meta"]; surveySummary: TSurveySummary["meta"];
quotasCount: number; quotasCount: number;
isLoading: boolean; isLoading: boolean;
tab: "dropOffs" | "quotas" | undefined; tab: "dropOffs" | "quotas" | "impressions" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>; setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>;
isQuotasAllowed: boolean; isQuotasAllowed: boolean;
} }
const formatTime = (ttc) => { const formatTime = (ttc: number) => {
const seconds = ttc / 1000; const seconds = ttc / 1000;
let formattedValue; let formattedValue;
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
const { t } = useTranslation(); const { t } = useTranslation();
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount; const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
const handleTabChange = (val: "dropOffs" | "quotas") => { const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
const change = tab === val ? undefined : val; const change = tab === val ? undefined : val;
setTab(change); setTab(change);
}; };
@@ -65,12 +65,16 @@ export const SummaryMetadata = ({
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`, `grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6" isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
)}> )}>
<StatCard <InteractiveCard
key="impressions"
tab="impressions"
label={t("environments.surveys.summary.impressions")} label={t("environments.surveys.summary.impressions")}
percentage={null} percentage={null}
value={displayCount === 0 ? <span>-</span> : displayCount} value={displayCount === 0 ? <span>-</span> : displayCount}
tooltipText={t("environments.surveys.summary.impressions_tooltip")} tooltipText={t("environments.surveys.summary.impressions_tooltip")}
isLoading={isLoading} isLoading={isLoading}
onClick={() => handleTabChange("impressions")}
isActive={tab === "impressions"}
/> />
<StatCard <StatCard
label={t("environments.surveys.summary.starts")} label={t("environments.surveys.summary.starts")}
@@ -1,21 +1,31 @@
"use client"; "use client";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"; import {
getDisplaysWithContactAction,
getSurveySummaryAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop"; import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs"; import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { SummaryImpressions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryImpressions";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys"; import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary"; import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
import { SummaryList } from "./SummaryList"; import { SummaryList } from "./SummaryList";
import { SummaryMetadata } from "./SummaryMetadata"; import { SummaryMetadata } from "./SummaryMetadata";
const DISPLAYS_PER_PAGE = 15;
const defaultSurveySummary: TSurveySummary = { const defaultSurveySummary: TSurveySummary = {
meta: { meta: {
completedPercentage: 0, completedPercentage: 0,
@@ -51,17 +61,76 @@ export const SummaryPage = ({
initialSurveySummary, initialSurveySummary,
isQuotasAllowed, isQuotasAllowed,
}: SummaryPageProps) => { }: SummaryPageProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>( const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary initialSurveySummary || defaultSurveySummary
); );
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined); const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary); const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter(); const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
const [hasMoreDisplays, setHasMoreDisplays] = useState(true);
const [displaysError, setDisplaysError] = useState<string | null>(null);
const displaysFetchedRef = useRef(false);
const fetchDisplays = useCallback(
async (offset: number) => {
const response = await getDisplaysWithContactAction({
surveyId,
limit: DISPLAYS_PER_PAGE,
offset,
});
if (!response?.data) {
const errorMessage = getFormattedErrorMessage(response);
throw new Error(errorMessage);
}
return response?.data ?? [];
},
[surveyId]
);
const loadInitialDisplays = useCallback(async () => {
setIsDisplaysLoading(true);
setDisplaysError(null);
try {
const data = await fetchDisplays(0);
setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
setDisplays([]);
setHasMoreDisplays(false);
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays, t]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
const data = await fetchDisplays(displays.length);
setDisplays((prev) => [...prev, ...data]);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
}
}, [fetchDisplays, displays.length, t]);
useEffect(() => {
if (tab === "impressions" && !displaysFetchedRef.current) {
displaysFetchedRef.current = true;
loadInitialDisplays();
}
}, [tab, loadInitialDisplays]);
// Only fetch data when filters change or when there's no initial data // Only fetch data when filters change or when there's no initial data
useEffect(() => { useEffect(() => {
// If we have initial data and no filters are applied, don't fetch // If we have initial data and no filters are applied, don't fetch
@@ -121,6 +190,18 @@ export const SummaryPage = ({
setTab={setTab} setTab={setTab}
isQuotasAllowed={isQuotasAllowed} isQuotasAllowed={isQuotasAllowed}
/> />
{tab === "impressions" && (
<SummaryImpressions
displays={displays}
isLoading={isDisplaysLoading}
hasMore={hasMoreDisplays}
displaysError={displaysError}
environmentId={environment.id}
locale={locale}
onLoadMore={handleLoadMoreDisplays}
onRetry={loadInitialDisplays}
/>
)}
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />} {tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />} {isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
<div className="flex gap-1.5"> <div className="flex gap-1.5">
@@ -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) {
@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card"; import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
interface InteractiveCardProps { interface InteractiveCardProps {
tab: "dropOffs" | "quotas"; tab: "dropOffs" | "quotas" | "impressions";
label: string; label: string;
percentage: number; percentage: number | null;
value: React.ReactNode; value: React.ReactNode;
tooltipText: string; tooltipText: string;
isLoading: boolean; isLoading: boolean;
@@ -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`}
@@ -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);
@@ -662,17 +662,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 +753,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 +842,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(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0); expect(choice.count).toBe(0);
expect(choice.avgRanking).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 +908,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(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0); expect(choice.count).toBe(0);
expect(choice.avgRanking).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 +974,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 +1008,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 +1046,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 +1069,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 +1872,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 +2019,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 +2154,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(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => {
expect(row.totalResponsesForRow).toBe(0); expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col) => { row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0); 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 +2252,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 +2363,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(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => {
expect(row.columnPercentages).toHaveLength(2); // 2 columns expect(row.columnPercentages).toHaveLength(2); // 2 columns
expect(row.totalResponsesForRow).toBe(0); expect(row.totalResponsesForRow).toBe(0);
expect(row.columnPercentages[0].percentage).toBe(0); expect(row.columnPercentages[0].percentage).toBe(0);
expect(row.columnPercentages[1].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 +2444,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 +2554,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 +2697,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 +2776,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(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => {
expect(row.totalResponsesForRow).toBe(0); expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col) => { row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0); expect(col.percentage).toBe(0);
}); });
}); }
);
}); });
}); });
@@ -2994,23 +3219,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 +3389,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(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0); expect(choice.count).toBe(0);
expect(choice.percentage).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 +3469,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 +3554,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(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0); expect(choice.count).toBe(0);
expect(choice.percentage).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 +3618,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();
}); });
}); });
@@ -14,11 +14,7 @@ import {
TResponseVariables, 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,
@@ -293,7 +289,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 +317,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 +827,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) => {
acc[row] = columns.reduce(
(colAcc: Record<string, number>, col) => {
colAcc[col] = 0; colAcc[col] = 0;
return colAcc; return colAcc;
}, {}); },
{} as Record<string, number>
);
return acc; 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 +1096,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;
@@ -40,10 +40,11 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new Error(t("common.user_not_found"));
} }
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 Error(t("common.organization_not_found"));
} }
@@ -51,7 +52,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!organizationBilling) { if (!organizationBilling) {
throw new Error(t("common.organization_not_found")); throw new Error(t("common.organization_not_found"));
} }
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);
@@ -4,18 +4,16 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { OperationNotAllowedError, 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 { ZSurvey } from "@formbricks/types/surveys/types";
import { getOrganization } from "@/lib/organization/service"; 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, updateSurvey } 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 { 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 { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission"; import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
@@ -28,7 +26,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 +56,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 +87,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),
@@ -115,17 +113,14 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
throw new ResourceNotFoundError("Organization not found", organizationId); throw new ResourceNotFoundError("Organization not found", organizationId);
} }
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan); const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) { if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization"); throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
} }
}; };
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action( export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging( withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
"updated",
"survey",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id); const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "", userId: ctx.user?.id ?? "",
@@ -155,10 +150,6 @@ export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).acti
await checkSurveyFollowUpsPermission(organizationId); await checkSurveyFollowUpsPermission(organizationId);
} }
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
// Context for audit log // Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id; ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.organizationId = organizationId;
@@ -169,6 +160,5 @@ export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).acti
ctx.auditLoggingCtx.newObject = newSurvey; ctx.auditLoggingCtx.newObject = newSurvey;
return 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);
} }

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