Compare commits

..

77 Commits

Author SHA1 Message Date
Dhruwang
5239dfd9b1 chore(db): add nullable workspaceId to environment-owned models
Phase 1 of environment deprecation: adds optional workspaceId column
with FK, index, and cascade delete to all 9 environment-owned models
(Survey, Contact, ActionClass, ContactAttributeKey, Webhook, Tag,
Segment, Integration, ApiKeyEnvironment) and reverse relations on
Workspace.

Replaces the previous projectId approach (reverted in the prior commit)
to align with the Project → Workspace rename from epic/v5.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 10:57:59 +05:30
Dhruwang
d45cbefcff Merge remote-tracking branch 'origin/epic/v5' into revert/remove-projectid-from-env-models
# Conflicts:
#	apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/page.tsx
#	apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/page.tsx
#	apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/page.tsx
#	apps/web/app/(app)/environments/[environmentId]/actions.ts
#	apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx
#	apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.tsx
#	apps/web/app/(app)/environments/[environmentId]/settings/(organization)/layout.tsx
#	apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx
#	apps/web/modules/ee/contacts/[contactId]/components/activity-section.tsx
#	apps/web/modules/ee/contacts/components/contacts-secondary-navigation.tsx
#	apps/web/modules/ee/contacts/layout.tsx
#	apps/web/modules/ee/whitelabel/remove-branding/actions.ts
#	apps/web/modules/environments/lib/utils.test.ts
#	apps/web/modules/environments/lib/utils.ts
#	apps/web/modules/projects/settings/general/components/delete-project.tsx
#	apps/web/modules/survey/editor/page.tsx
#	apps/web/modules/survey/list/page.tsx
#	apps/web/modules/survey/templates/page.tsx
#	apps/web/modules/workspaces/settings/actions.ts
#	apps/web/modules/workspaces/settings/look/page.tsx
2026-04-01 10:47:48 +05:30
Dhruwang
f1c6180ae2 Revert "chore(db): add nullable projectId to environment-owned models (#7588)"
This reverts commit 71cca557fc.
2026-04-01 10:27:41 +05:30
Dhruwang Jariwala
a771ae189a refactor: rename Project to Workspace across entire codebase (#7620) 2026-03-31 17:01:17 +05:30
Anshuman Pandey
029e069af6 feat: feedback record directories (#7592) 2026-03-27 04:18:20 -07:00
Dhruwang Jariwala
71cca557fc chore(db): add nullable projectId to environment-owned models (#7588) 2026-03-27 12:33:44 +05:30
Dhruwang Jariwala
1500b6f7f3 docs: deprecate environments migration plan (#7586)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 16:34:40 +04:00
Dhruwang
2c9fbf83e4 chore: merge epic/v5 into chore/deprecate-environments 2026-03-26 15:10:31 +05:30
Dhruwang Jariwala
59cc9c564e fix: duplicate org creation (#7593) 2026-03-26 05:52:09 +00:00
Dhruwang Jariwala
20dc147682 fix: scrolling behaviour to invalid questions (#7573) 2026-03-25 13:35:51 +00:00
cursor[bot]
2bb7a6f277 fix: prevent TypeError when checking for duplicate matrix labels (#7579)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-03-25 13:14:18 +00:00
Matti Nannt
81272b96e1 feat: port hub xm-suite config to epic/v5 (#7578) 2026-03-25 11:04:42 +00:00
Dhruwang Jariwala
deb062dd03 fix: handle 404 race condition in Stripe webhook reconciliation (#7584) 2026-03-25 09:58:00 +00:00
Dhruwang Jariwala
474be86d33 fix: translations for option types (#7576) 2026-03-24 13:18:26 +00:00
Dhruwang Jariwala
e7ca66ed77 fix: use TTC data for reliable survey impression counting (#7572) 2026-03-24 08:52:35 +00:00
Matti Nannt
2b49dbecd3 chore: add dev:setup script to generate .env and missing secrets (#7555)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 08:26:32 +00:00
Anshuman Pandey
6da4c6f352 fix: proper errors server side when resources are not found (#7571) 2026-03-24 07:52:37 +00:00
Aryan Ghugare
659b240fca feat: enhance welcome card to support video uploads and display #7491 (#7497)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 07:34:43 +00:00
Dhruwang Jariwala
19c0b1d14d fix: response table settings formatting (#7540)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 06:36:45 +00:00
Dhruwang Jariwala
b4472f48e9 fix: (Duplicate) prevent multi-language survey buttons from falling back to English (#7559) 2026-03-24 05:45:47 +00:00
bharath kumar
d197271771 fix(web): add <noscript> message for when JS is disabled (#7455) (#7459)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-23 12:35:29 +00:00
Dhruwang Jariwala
37f652c70e fix: prevent session expiry during active use (#7558) 2026-03-23 10:44:55 +00:00
Matti Nannt
645f0ab0d1 fix: resolve remaining dependabot alerts (#7561) 2026-03-23 09:59:01 +00:00
Johannes
389a7d9e7b feat: enhance segment activity summary and settings in segment modal (#7553)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-23 08:39:10 +00:00
Tiago
c4cf468c7e fix: localize survey and app date rendering (#7473)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-23 07:23:07 +00:00
Johannes
cbc3e923e4 fix: segment targeting "isNotIn" didnt work (#7550) 2026-03-23 05:22:19 +00:00
Tiago
a96ba8b1e7 docs: clarify v2 contact API request body shapes (#1089) (#7552) 2026-03-20 16:23:06 +00:00
Johannes
e830871361 docs: update docs re multi-lang (#7547) 2026-03-20 15:56:03 +00:00
Matti Nannt
998e5c0819 fix: resolve high severity dependabot alerts (#7551) 2026-03-20 15:55:15 +00:00
Balázs Úr
13a56b0237 fix: mark language selector tooltip as translatable (#7520) 2026-03-20 12:17:26 +00:00
Dhruwang Jariwala
0b5418a03a feat: searchable dropdown (#7530)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-20 12:15:48 +00:00
Anshuman Pandey
0d8a338965 fix: fixes welcome card logo removal bug (#7544) 2026-03-20 10:06:01 +00:00
Tiago
d3250736a9 feat: add V3 surveys API (#7499) 2026-03-20 09:55:33 +00:00
Dhruwang Jariwala
e6ee6a6b0d feat: choice rotation (#7512)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-20 06:54:05 +00:00
Dhruwang Jariwala
c0b097f929 refactor: update CTA component styles and utility class groups (#7532) 2026-03-20 06:43:35 +00:00
Tiago
78d336f8c7 chore: Improve the webhook "Test Endpoint" feature (#7527) 2026-03-19 16:13:48 +01:00
Dhruwang Jariwala
95a7a265b9 feat: enhance survey display in webhook row with limited visibility (#7535) 2026-03-19 12:56:53 +00:00
Dhruwang Jariwala
136e59da68 fix: allow survey updation without followup access (#7528) 2026-03-19 11:42:14 +00:00
Anshuman Pandey
eb0a87cf80 fix: fixes the loading skeleton on workspaces/tags page and some sentry improvements (#7533) 2026-03-19 11:09:52 +00:00
Anshuman Pandey
0dcb98ac29 fix: sdk init issues (#7516)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-19 11:04:12 +00:00
Balázs Úr
540f7aaae7 chore: change LINGO_API_KEY environment variable name (#7521) 2026-03-19 07:30:44 +00:00
Dhruwang Jariwala
2d4614a0bd chore: forward customer state to chatwoot (#7518) 2026-03-19 07:13:23 +00:00
Dhruwang Jariwala
633bf18204 fix: auto-expand multi-language card when toggle is enabled (#7504)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 12:18:35 +00:00
Balázs Úr
9a6cbd05b6 fix: mark various strings as translatable (#7338)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 11:30:38 +00:00
Johannes
94b0248075 fix: only allow URL in exact match URL (#7505)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 07:20:14 +00:00
Johannes
082de1042d feat: add validation for custom survey closed message heading (#7502) 2026-03-18 06:40:57 +00:00
Johannes
8c19587baa fix: ensure at least one filter is required for segments (#7503)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 06:39:58 +00:00
Anshuman Pandey
433750d3fe fix: removes pino pretty from edge runtime (#7510) 2026-03-18 06:32:55 +00:00
Johannes
61befd5ffd feat: add enterprise license features table (#7492)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 06:14:40 +00:00
Dhruwang Jariwala
1e7817fb69 fix: pre-strip style attributes before DOMPurify to prevent CSP violations (#7489)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-17 15:33:44 +00:00
Anshuman Pandey
f250bc7e88 fix: fixes race between setUserId and trigger (#7498)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 08:57:07 +00:00
Santosh
c7faa29437 fix: derive organizationId from resources in server actions to prevent cross-org IDOR (#7409)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 05:36:58 +00:00
Anshuman Pandey
a51a006c26 fix: fixes data element i18n fixes (#7488) 2026-03-16 10:12:48 +00:00
Matti Nannt
ce96cb0b89 feat: replace hosted stripe pricing table (#7486)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-16 10:11:40 +00:00
Matti Nannt
fb265d9dba feat: add SAML telemetry reporting (#7461) 2026-03-16 09:41:33 +00:00
Matti Nannt
e4c155b501 fix: defer hobby subscription creation (#7484) 2026-03-15 14:13:53 +00:00
Johannes
2dc5c50f4d feat: implement trial days remaining alert in billing components (#7474) 2026-03-13 16:38:43 +01:00
Anshuman Pandey
bddcec0466 fix: adds monkey patching for replaceState (#7475) 2026-03-13 13:40:20 +00:00
Dhruwang Jariwala
92677e1ec0 fix: respect overwriteThemeStyling in link survey metadata (#7466)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-13 13:07:54 +00:00
Anshuman Pandey
b12228e305 fix: fixes button url fixes in survey editor (#7472) 2026-03-13 13:07:41 +00:00
Dhruwang Jariwala
91be2af30b fix: add missing Stripe billing setup for setup route org creation (#7470)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:18:01 +01:00
Anshuman Pandey
84c668be86 fix: fixes contact links api gating issue (#7468) 2026-03-13 11:09:53 +00:00
Dhruwang Jariwala
4015c76f2b fix: use logical CSS direction classes for RTL matrix question (#7463)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:06:41 +00:00
Dhruwang Jariwala
a7b2ade4a9 fix: remove follow-ups from trial features and gate trial page for subscribers (#7465)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:00:23 +00:00
Dhruwang Jariwala
75f44952c7 fix: clear validation settings when disabling open text validation (#7464)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 09:39:42 +00:00
Bhagya Amarasinghe
0df5e26381 fix: handle license 403 as instance mismatch (#7458)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-12 10:46:44 +00:00
Matti Nannt
89bb3bcd84 chore: apply NCU minor upgrades fixups (#7460) 2026-03-12 10:44:18 +00:00
Harsh Bhat
30fdb72c09 feat: add PostHog analytics (#7454)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-12 09:53:14 +01:00
Matti Nannt
cb58cf5825 fix: restrict selected entitlements during trial (#7456)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-12 08:10:23 +00:00
Johannes
99bd2ba256 feat: add reverse trial functionality (#7435)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-03-11 14:47:48 +00:00
Anshuman Pandey
9df423073f fix: zlib CVE (#7444) 2026-03-11 11:10:29 +00:00
Johannes
3e3c696972 feat: add trigger after time passed (#7452)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-11 10:12:31 +00:00
Anshuman Pandey
cb41e2d344 fix: sets apps/web TS strict check to true (#7451) 2026-03-11 10:14:37 +01:00
Matti Nannt
1e19cca7d9 feat: implement cloud stripe billing sync and pricing revamp (#7309)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 16:04:23 +00:00
Johannes
fa882dd4cc fix: improve survey validation error handling in SurveyMenuBar component (#7447) 2026-03-10 10:23:05 +00:00
Matti Nannt
0b82c6de77 feat: move multi-language surveys and workspace languages to AGPL (#7426)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-10 09:28:01 +00:00
Balázs Úr
a944d7626e chore: use Unicode punctuation, remove contractions, make wording consistent (#7355)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 07:06:30 +00:00
1074 changed files with 33377 additions and 17437 deletions

View File

@@ -38,6 +38,15 @@ LOG_LEVEL=info
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
################
# MAIL SETUP #
################
@@ -150,6 +159,7 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
@@ -230,4 +240,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here
LINGO_API_KEY=your_api_key_here

View File

@@ -285,12 +285,14 @@ runs:
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
posthog_key=${{ env.POSTHOG_KEY }}
env:
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
- 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') }}

View File

@@ -92,3 +92,4 @@ jobs:
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}

View File

@@ -52,6 +52,14 @@ We are using SonarQube to identify code smells and security hotspots.
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
## Date and Time Rendering
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
## Database & Prisma Performance
- Multi-tenancy: All data must be scoped by Organization or Environment.

View File

@@ -12,18 +12,18 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.15",
"@storybook/addon-links": "10.2.15",
"@storybook/addon-onboarding": "10.2.15",
"@storybook/react-vite": "10.2.15",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@storybook/addon-a11y": "10.2.17",
"@storybook/addon-links": "10.2.17",
"@storybook/addon-onboarding": "10.2.17",
"@storybook/react-vite": "10.2.17",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.56.1",
"@typescript-eslint/parser": "8.57.0",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.14",
"storybook": "10.2.15",
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.15"
"@storybook/addon-docs": "10.2.17"
}
}

View File

@@ -18,7 +18,7 @@ FROM node:24-alpine3.23 AS base
FROM base AS installer
# 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 prepare pnpm@10.28.2 --activate
@@ -67,6 +67,7 @@ RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \
--mount=type=secret,id=redis_url \
--mount=type=secret,id=sentry_auth_token \
--mount=type=secret,id=posthog_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
#
@@ -74,9 +75,10 @@ RUN --mount=type=secret,id=database_url \
#
FROM base AS runner
# Update npm to latest, then create user
# Upgrade Alpine system packages to pick up security patches, update npm to latest, then create user
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
RUN npm install --ignore-scripts -g npm@latest \
RUN apk update && apk upgrade --no-cache \
&& npm install --ignore-scripts -g npm@latest \
&& addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
@@ -120,8 +122,11 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
RUN chmod -R 755 ./node_modules/uuid
# Runtime migrations import uuid v7 from the database package, so copy the
# 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
RUN chmod -R 755 ./node_modules/@noble/hashes
@@ -164,4 +169,4 @@ RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
VOLUME /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"]
CMD ["/home/nextjs/start.sh"]

View File

@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
@@ -14,7 +14,7 @@ interface ConnectWithFormbricksProps {
environment: TEnvironment;
publicDomain: string;
appSetupCompleted: boolean;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
}
export const ConnectWithFormbricks = ({

View File

@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
@@ -19,7 +19,7 @@ const tabs = [
interface OnboardingSetupInstructionsProps {
environmentId: string;
publicDomain: string;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
appSetupCompleted: boolean;
}

View File

@@ -1,9 +1,10 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
@@ -20,15 +21,15 @@ const Page = async (props: ConnectPageProps) => {
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
const workspace = await getWorkspaceByEnvironmentId(environment.id);
if (!workspace) {
throw new Error(t("common.workspace_not_found"));
}
const channel = project.config.channel || null;
const channel = workspace.config.channel || null;
const publicDomain = getPublicDomain();

View File

@@ -4,7 +4,10 @@ import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
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 { children } = props;

View File

@@ -5,10 +5,10 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProject } from "@formbricks/types/project";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
@@ -16,12 +16,12 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
interface XMTemplateListProps {
project: TProject;
workspace: TWorkspace;
user: TUser;
environmentId: string;
}
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
export const XMTemplateList = ({ workspace, user, environmentId }: XMTemplateListProps) => {
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
const { t } = useTranslation();
const router = useRouter();
@@ -48,7 +48,7 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
const handleTemplateClick = (templateIdx: number) => {
setActiveTemplateId(templateIdx);
const template = getXMTemplates(t)[templateIdx];
const newTemplate = replacePresetPlaceholders(template, project);
const newTemplate = replacePresetPlaceholders(template, workspace);
createSurvey(newTemplate);
};

View File

@@ -1,16 +1,17 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "./utils";
// Mock data
const mockProject: TProject = {
id: "project1",
const mockWorkspace: TWorkspace = {
id: "workspace1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
name: "Test Workspace",
organizationId: "org1",
styling: {
allowStyleOverwrite: true,
@@ -31,7 +32,7 @@ const mockProject: TProject = {
logo: null,
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
name: "$[workspaceName] Survey",
blocks: [
{
id: "block1",
@@ -39,13 +40,13 @@ const mockTemplate: TXMTemplate = {
elements: [
{
id: "q1",
type: "openText" as const,
type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
headline: { default: "$[workspaceName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
charLimit: 1000,
charLimit: { enabled: true, max: 1000 },
},
],
},
@@ -69,19 +70,19 @@ describe("replacePresetPlaceholders", () => {
cleanup();
});
test("replaces projectName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.name).toBe("Test Project Survey");
test("replaces workspaceName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.name).toBe("Test Workspace Survey");
});
test("replaces projectName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
test("replaces workspaceName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question");
});
test("returns a new object without mutating the original template", () => {
const originalTemplate = structuredClone(mockTemplate);
const result = replacePresetPlaceholders(mockTemplate, mockProject);
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result).not.toBe(mockTemplate);
expect(mockTemplate).toEqual(originalTemplate);
});

View File

@@ -1,16 +1,16 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
// replace all occurences of workspaceName with the actual workspace name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => {
const survey = structuredClone(template);
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, workspace)),
}));
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
return { ...survey, name: survey.name.replace("$[workspaceName]", workspace.name), blocks: modifiedBlocks };
};

View File

@@ -14,7 +14,7 @@ describe("xm-templates", () => {
});
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);
expect(result).toEqual({
@@ -29,7 +29,7 @@ describe("xm-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);
expect(result).toHaveLength(6);
@@ -44,7 +44,7 @@ describe("xm-templates", () => {
test("getXMTemplates handles errors gracefully", async () => {
const tMock = vi.fn(() => {
throw new Error("Test error");
}) as TFunction;
}) as unknown as TFunction;
const result = getXMTemplates(tMock);

View File

@@ -1,11 +1,12 @@
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getUserWorkspaces, getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button";
@@ -23,31 +24,31 @@ const Page = async (props: XMTemplatePageProps) => {
const environment = await getEnvironment(params.environmentId);
const t = await getTranslate();
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
const workspace = await getWorkspaceByEnvironmentId(environment.id);
if (!workspace) {
throw new Error(t("common.workspace_not_found"));
}
const projects = await getUserProjects(session.user.id, organizationId);
const workspaces = await getUserWorkspaces(session.user.id, organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("environments.xm-templates.headline")} />
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<XMTemplateList workspace={workspace} user={user} environmentId={environment.id} />
{workspaces.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"

View File

@@ -19,8 +19,8 @@ describe("getTeamsByOrganizationId", () => {
test("returns mapped teams", async () => {
const mockTeams = [
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
{ id: "t1", name: "Team 1", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
{ id: "t2", name: "Team 2", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
];
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1");

View File

@@ -22,12 +22,10 @@ export const getTeamsByOrganizationId = reactCache(
},
});
const projectTeams = teams.map((team) => ({
return teams.map((team: TOrganizationTeam) => ({
id: team.id,
name: team.name,
}));
return projectTeams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);

View File

@@ -2,10 +2,13 @@ import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
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 { children } = props;
@@ -21,11 +24,11 @@ const LandingLayout = async (props) => {
return notFound();
}
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
if (projects.length !== 0) {
const firstProject = projects[0];
const environments = await getEnvironments(firstProject.id);
if (workspaces.length !== 0) {
const firstWorkspace = workspaces[0];
const environments = await getEnvironments(firstWorkspace.id);
const prodEnvironment = environments.find((e) => e.type === "production");
if (prodEnvironment) {

View File

@@ -1,6 +1,6 @@
import { notFound, redirect } from "next/navigation";
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/workspace-and-org-switch";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -10,7 +10,7 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
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 t = await getTranslate();
@@ -34,12 +34,12 @@ const Page = async (props) => {
<div className="flex-1">
<div className="flex h-full flex-col">
<div className="p-6">
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
<ProjectAndOrgSwitch
{/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */}
<WorkspaceAndOrgSwitch
currentOrganizationId={organization.id}
currentOrganizationName={organization.name}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={0}
organizationWorkspacesLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={false}
isOwnerOrManager={false}

View File

@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
@@ -8,7 +8,10 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props) => {
const WorkspaceOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
@@ -22,7 +25,7 @@ const ProjectOnboardingLayout = async (props) => {
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
@@ -33,7 +36,7 @@ const ProjectOnboardingLayout = async (props) => {
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
return (
@@ -44,4 +47,4 @@ const ProjectOnboardingLayout = async (props) => {
);
};
export default ProjectOnboardingLayout;
export default WorkspaceOnboardingLayout;

View File

@@ -2,7 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -39,7 +39,7 @@ const Page = async (props: ChannelPageProps) => {
},
];
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
@@ -48,7 +48,7 @@ const Page = async (props: ChannelPageProps) => {
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
/>
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"

View File

@@ -1,14 +1,18 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationWorkspacesLimit } 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 { children } = props;
@@ -25,13 +29,15 @@ const OnboardingLayout = async (props) => {
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([
getOrganizationWorkspacesLimit(organization.id),
getOrganizationWorkspacesCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) {
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
return redirect(`/`);
}

View File

@@ -2,7 +2,7 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -39,13 +39,13 @@ const Page = async (props: ModePageProps) => {
},
];
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -8,19 +8,19 @@ import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import {
TProjectConfigChannel,
TProjectConfigIndustry,
TProjectMode,
TProjectUpdateInput,
ZProjectUpdateInput,
} from "@formbricks/types/project";
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
TWorkspaceUpdateInput,
ZWorkspaceUpdateInput,
} from "@formbricks/types/workspace";
import { createWorkspaceAction } from "@/app/(app)/environments/[environmentId]/actions";
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import {
@@ -36,34 +36,34 @@ import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { SurveyInline } from "@/modules/ui/components/survey";
interface ProjectSettingsProps {
interface WorkspaceSettingsProps {
organizationId: string;
projectMode: TProjectMode;
channel: TProjectConfigChannel;
industry: TProjectConfigIndustry;
workspaceMode: TWorkspaceMode;
channel: TWorkspaceConfigChannel;
industry: TWorkspaceConfigIndustry;
defaultBrandColor: string;
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
userWorkspacesCount: number;
publicDomain: string;
}
export const ProjectSettings = ({
export const WorkspaceSettings = ({
organizationId,
projectMode,
workspaceMode,
channel,
industry,
defaultBrandColor,
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
userWorkspacesCount,
publicDomain,
}: ProjectSettingsProps) => {
}: WorkspaceSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const router = useRouter();
const { t } = useTranslation();
const addProject = async (data: TProjectUpdateInput) => {
const addWorkspace = async (data: TWorkspaceUpdateInput) => {
try {
// Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted.
@@ -71,7 +71,7 @@ export const ProjectSettings = ({
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createProjectResponse = await createProjectAction({
const createWorkspaceResponse = await createWorkspaceAction({
organizationId,
data: {
...data,
@@ -81,14 +81,14 @@ export const ProjectSettings = ({
},
});
if (createProjectResponse?.data) {
if (createWorkspaceResponse?.data) {
// get production environment
const productionEnvironment = createProjectResponse.data.environments.find(
(environment) => environment.type === "production"
const productionEnvironment = createWorkspaceResponse.data.environments.find(
(environment: { type: string }) => environment.type === "production"
);
if (productionEnvironment) {
if (globalThis.window !== undefined) {
// Rmove filters when creating a new project
// Remove filters when creating a new workspace
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
}
@@ -96,11 +96,11 @@ export const ProjectSettings = ({
router.push(`/environments/${productionEnvironment?.id}/connect`);
} else if (channel === "link") {
router.push(`/environments/${productionEnvironment?.id}/surveys`);
} else if (projectMode === "cx") {
} else if (workspaceMode === "cx") {
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
}
} else {
const errorMessage = getFormattedErrorMessage(createProjectResponse);
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
toast.error(errorMessage);
}
} catch (error) {
@@ -109,15 +109,15 @@ export const ProjectSettings = ({
}
};
const form = useForm<TProjectUpdateInput>({
const form = useForm<TWorkspaceUpdateInput>({
defaultValues: {
name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
teamIds: [],
},
resolver: zodResolver(ZProjectUpdateInput),
resolver: zodResolver(ZWorkspaceUpdateInput),
});
const projectName = form.watch("name");
const workspaceName = form.watch("name");
const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
@@ -132,7 +132,7 @@ export const ProjectSettings = ({
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col space-y-4">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(addProject)} className="w-full space-y-4">
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4">
<FormField
control={form.control}
name="styling.brandColor.light"
@@ -184,7 +184,7 @@ export const ProjectSettings = ({
)}
/>
{isAccessControlAllowed && userProjectsCount > 0 && (
{isAccessControlAllowed && userWorkspacesCount > 0 && (
<FormField
control={form.control}
name="teamIds"
@@ -242,7 +242,7 @@ export const ProjectSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"

View File

@@ -1,30 +1,35 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
} from "@formbricks/types/workspace";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface ProjectSettingsPageProps {
interface WorkspaceSettingsPageProps {
params: Promise<{
organizationId: string;
}>;
searchParams: Promise<{
channel?: TProjectConfigChannel;
industry?: TProjectConfigIndustry;
mode?: TProjectMode;
channel?: TWorkspaceConfigChannel;
industry?: TWorkspaceConfigIndustry;
mode?: TWorkspaceMode;
}>;
}
const Page = async (props: ProjectSettingsPageProps) => {
const Page = async (props: WorkspaceSettingsPageProps) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();
@@ -38,14 +43,14 @@ const Page = async (props: ProjectSettingsPageProps) => {
const channel = searchParams.channel ?? null;
const industry = searchParams.industry ?? null;
const mode = searchParams.mode ?? "surveys";
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
throw new ResourceNotFoundError(t("common.team"), null);
}
const publicDomain = getPublicDomain();
@@ -56,18 +61,18 @@ const Page = async (props: ProjectSettingsPageProps) => {
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
/>
<ProjectSettings
<WorkspaceSettings
organizationId={params.organizationId}
projectMode={mode}
workspaceMode={mode}
channel={channel}
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
userWorkspacesCount={workspaces.length}
publicDomain={publicDomain}
/>
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"

View File

@@ -16,7 +16,7 @@ interface OnboardingOptionsContainerProps {
}
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
const getOptionCard = (option) => {
const getOptionCard = (option: OnboardingOptionsContainerProps["options"][number]) => {
const Icon = option.icon;
return (
<OptionCard

View File

@@ -1,8 +1,12 @@
import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service";
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 { children } = props;
@@ -14,13 +18,13 @@ const SurveyEditorEnvironmentLayout = async (props) => {
}
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
return (

View File

@@ -6,15 +6,26 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti";
interface ConfirmationPageProps {
environmentId: string;
}
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
export const ConfirmationPage = () => {
const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false);
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
useEffect(() => {
setShowConfetti(true);
if (globalThis.window === undefined) {
return;
}
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
);
if (storedEnvironmentId) {
setResolvedEnvironmentId(storedEnvironmentId);
}
}, []);
return (
@@ -30,7 +41,12 @@ export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
</p>
</div>
<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")}
</Link>
</Button>

View File

@@ -3,13 +3,10 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
export const dynamic = "force-dynamic";
const Page = async (props) => {
const searchParams = await props.searchParams;
const { environmentId } = searchParams;
const Page = async () => {
return (
<PageContentWrapper>
<ConfirmationPage environmentId={environmentId?.toString()} />
<ConfirmationPage />
</PageContentWrapper>
);
};

View File

@@ -2,90 +2,89 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import {
AuthorizationError,
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getOrganizationWorkspacesLimit,
} from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
import { getOrganizationsByUserId } from "./lib/organization";
import { getProjectsByUserId } from "./lib/project";
import { getWorkspacesByUserId } from "./lib/workspace";
const ZCreateProjectAction = z.object({
const ZCreateWorkspaceAction = z.object({
organizationId: ZId,
data: ZProjectUpdateInput,
data: ZWorkspaceUpdateInput,
});
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging(
"created",
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx;
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
withAuditLogging("created", "workspace", async ({ ctx, parsedInput }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId;
const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZWorkspaceUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
};
],
});
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
const organization = await getOrganization(organizationId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId);
}
)
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
const organizationWorkspacesCount = await getOrganizationWorkspacesCount(organization.id);
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const workspace = await createWorkspace(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.workspaceId = workspace.id;
ctx.auditLoggingCtx.newObject = workspace;
return workspace;
})
);
const ZGetOrganizationsForSwitcherAction = z.object({
@@ -113,16 +112,16 @@ export const getOrganizationsForSwitcherAction = authenticatedActionClient
return await getOrganizationsByUserId(ctx.user.id);
});
const ZGetProjectsForSwitcherAction = z.object({
const ZGetWorkspacesForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query
});
/**
* Fetches projects list for switcher dropdown.
* Called on-demand when user opens the project switcher.
* Called on-demand when user opens the workspace switcher.
*/
export const getProjectsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetProjectsForSwitcherAction)
export const getWorkspacesForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetWorkspacesForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -135,11 +134,11 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
],
});
// Need membership for getProjectsByUserId (1 DB query)
// Need membership for getWorkspacesByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) {
throw new AuthorizationError("Membership not found");
}
return await getProjectsByUserId(ctx.user.id, membership);
return await getWorkspacesByUserId(ctx.user.id, membership);
});

View File

@@ -1,10 +1,11 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
@@ -24,12 +25,11 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
environment,
organization,
membership,
project, // Current project details
environments, // All project environments (for environment switcher)
workspace, // Current workspace details
environments, // All workspace environments (for environment switcher)
isAccessControlAllowed,
projectPermission,
workspacePermission,
license,
peopleCount,
responseCount,
} = layoutData;
@@ -38,11 +38,11 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members
if (isMember && !projectPermission) {
// Validate that workspace permission exists for members
if (isMember && !workspacePermission) {
throw new Error(t("common.workspace_permission_not_found"));
}
@@ -52,7 +52,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<LimitsReachedBanner
organization={organization}
environmentId={environment.id}
peopleCount={peopleCount}
responseCount={responseCount}
/>
)}
@@ -71,7 +70,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
environment={environment}
organization={organization}
user={user}
project={{ id: project.id, name: project.name }}
workspace={{ id: workspace.id, name: workspace.name }}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
@@ -81,9 +80,9 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<TopControlBar
environments={environments}
currentOrganizationId={organization.id}
currentProjectId={project.id}
currentWorkspaceId={workspace.id}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={active}
isOwnerOrManager={isOwnerOrManager}

View File

@@ -28,7 +28,7 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import {
@@ -37,13 +37,14 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { getLatestStableFbReleaseAction } from "@/modules/workspaces/settings/(setup)/app-connection/actions";
import packageJson from "../../../../../package.json";
interface NavigationProps {
environment: TEnvironment;
user: TUser;
organization: TOrganization;
project: { id: string; name: string };
workspace: { id: string; name: string };
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
@@ -54,7 +55,7 @@ export const MainNavigation = ({
environment,
organization,
user,
project,
workspace,
membershipRole,
isFormbricksCloud,
isDevelopment,
@@ -91,7 +92,7 @@ export const MainNavigation = ({
}, [isCollapsed]);
useEffect(() => {
// Auto collapse project navbar on org and account settings
// Auto collapse workspace navbar on org and account settings
if (pathname?.includes("/settings")) {
setIsCollapsed(true);
}
@@ -126,7 +127,7 @@ export const MainNavigation = ({
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/project"),
isActive: pathname?.includes("/workspace"),
},
],
[t, environment.id, pathname, isFormbricksCloud]
@@ -167,11 +168,25 @@ export const MainNavigation = ({
if (isOwnerOrManager) loadReleases();
}, [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/"}`;
return (
<>
{project && (
{workspace && (
<aside
className={cn(
"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",
@@ -241,6 +256,13 @@ export const MainNavigation = ({
</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 */}
<div className="flex items-center">
<DropdownMenu>

View File

@@ -2,16 +2,16 @@
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/workspace-and-org-switch";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getAccessFlags } from "@/lib/membership/utils";
interface TopControlBarProps {
environments: TEnvironment[];
currentOrganizationId: string;
currentProjectId: string;
currentWorkspaceId: string;
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
@@ -22,9 +22,9 @@ interface TopControlBarProps {
export const TopControlBar = ({
environments,
currentOrganizationId,
currentProjectId,
currentWorkspaceId,
isMultiOrgEnabled,
organizationProjectsLimit,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
@@ -38,13 +38,13 @@ export const TopControlBar = ({
<div
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
data-testid="fb__global-top-control-bar">
<ProjectAndOrgSwitch
<WorkspaceAndOrgSwitch
currentEnvironmentId={environment.id}
environments={environments}
currentOrganizationId={currentOrganizationId}
currentProjectId={currentProjectId}
currentWorkspaceId={currentWorkspaceId}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager}

View File

@@ -1,13 +1,13 @@
import Link from "next/link";
import { ReactNode } from "react";
interface ProjectNavItemProps {
interface WorkspaceNavItemProps {
href: string;
children: ReactNode;
isActive: boolean;
}
export const ProjectNavItem = ({ href, children, isActive }: ProjectNavItemProps) => {
export const WorkspaceNavItem = ({ href, children, isActive }: WorkspaceNavItemProps) => {
const activeClass = "bg-slate-50 font-semibold";
const inactiveClass = "hover:bg-slate-50";

View File

@@ -138,6 +138,12 @@ export const OrganizationBreadcrumb = ({
label: t("common.members_and_teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`,
},
{
id: "feedback-record-directories",
label: t("environments.settings.feedback_record_directories.nav_label"),
href: `/environments/${currentEnvironmentId}/settings/feedback-record-directories`,
hidden: isMember,
},
{
id: "api-keys",
label: t("common.api_keys"),

View File

@@ -2,18 +2,18 @@
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
import { WorkspaceBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/workspace-breadcrumb";
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
interface ProjectAndOrgSwitchProps {
interface WorkspaceAndOrgSwitchProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: for pages without context
currentProjectId?: string;
currentProjectName?: string; // Optional: for pages without context
currentWorkspaceId?: string;
currentWorkspaceName?: string; // Optional: for pages without context
currentEnvironmentId?: string;
environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
@@ -21,21 +21,21 @@ interface ProjectAndOrgSwitchProps {
isAccessControlAllowed: boolean;
}
export const ProjectAndOrgSwitch = ({
export const WorkspaceAndOrgSwitch = ({
currentOrganizationId,
currentOrganizationName,
currentProjectId,
currentProjectName,
currentWorkspaceId,
currentWorkspaceName,
currentEnvironmentId,
environments,
isMultiOrgEnabled,
organizationProjectsLimit,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
isMember,
}: ProjectAndOrgSwitchProps) => {
}: WorkspaceAndOrgSwitchProps) => {
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
@@ -51,14 +51,14 @@ export const ProjectAndOrgSwitch = ({
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
/>
{currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb
currentProjectId={currentProjectId}
currentProjectName={currentProjectName}
{currentWorkspaceId && currentEnvironmentId && (
<WorkspaceBreadcrumb
currentWorkspaceId={currentWorkspaceId}
currentWorkspaceName={currentWorkspaceName}
currentOrganizationId={currentOrganizationId}
currentEnvironmentId={currentEnvironmentId}
isOwnerOrManager={isOwnerOrManager}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}

View File

@@ -6,10 +6,8 @@ import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getWorkspacesForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
@@ -20,13 +18,15 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context";
import { CreateWorkspaceModal } from "@/modules/workspaces/components/create-workspace-modal";
import { WorkspaceLimitModal } from "@/modules/workspaces/components/workspace-limit-modal";
import { useWorkspace } from "../context/environment-context";
interface ProjectBreadcrumbProps {
currentProjectId: string;
currentProjectName?: string; // Optional: pass directly if context not available
interface WorkspaceBreadcrumbProps {
currentWorkspaceId: string;
currentWorkspaceName?: string; // Optional: pass directly if context not available
isOwnerOrManager: boolean;
organizationProjectsLimit: number;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
currentOrganizationId: string;
@@ -35,7 +35,7 @@ interface ProjectBreadcrumbProps {
isEnvironmentBreadcrumbVisible: boolean;
}
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
const isActiveWorkspaceSetting = (pathname: string, settingId: string): boolean => {
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
if (pathname.includes("/settings/")) {
return false;
@@ -45,59 +45,59 @@ const isActiveProjectSetting = (pathname: string, settingId: string): boolean =>
return pattern.test(pathname);
};
export const ProjectBreadcrumb = ({
currentProjectId,
currentProjectName,
export const WorkspaceBreadcrumb = ({
currentWorkspaceId,
currentWorkspaceName,
isOwnerOrManager,
organizationProjectsLimit,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
currentOrganizationId,
currentEnvironmentId,
isAccessControlAllowed,
isEnvironmentBreadcrumbVisible,
}: ProjectBreadcrumbProps) => {
}: WorkspaceBreadcrumbProps) => {
const { t } = useTranslation();
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
const [openCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useState(false);
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false);
const [workspaces, setWorkspaces] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
// Get current project name from context OR prop
// Get current workspace name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { project: currentProject } = useProject();
const projectName = currentProject?.name || currentProjectName || "";
const { workspace: currentWorkspace } = useWorkspace();
const workspaceName = currentWorkspace?.name || currentWorkspaceName || "";
// Lazy-load projects when dropdown opens
// Lazy-load workspaces when dropdown opens
useEffect(() => {
// Only fetch when dropdown opened for first time (and no error state)
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
setIsLoadingProjects(true);
if (isWorkspaceDropdownOpen && workspaces.length === 0 && !isLoadingWorkspaces && !loadError) {
setIsLoadingWorkspaces(true);
setLoadError(null); // Clear any previous errors
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
getWorkspacesForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
// Sort workspaces by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
setWorkspaces(sorted);
} else {
// Handle server errors or validation errors
const errorMessage = getFormattedErrorMessage(result);
const error = new Error(errorMessage);
logger.error(error, "Failed to load projects");
logger.error(error, "Failed to load workspaces");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
}
setIsLoadingProjects(false);
setIsLoadingWorkspaces(false);
});
}
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
}, [isWorkspaceDropdownOpen, currentOrganizationId, workspaces.length, isLoadingWorkspaces, loadError, t]);
const projectSettings = [
const workspaceSettings = [
{
id: "general",
label: t("common.general"),
@@ -135,29 +135,29 @@ export const ProjectBreadcrumb = ({
},
];
if (!currentProject) {
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
if (!currentWorkspace) {
const errorMessage = `Workspace not found for workspace id: ${currentWorkspaceId}`;
logger.error(errorMessage);
Sentry.captureException(new Error(errorMessage));
return;
}
const handleProjectChange = (projectId: string) => {
if (projectId === currentProjectId) return;
const handleWorkspaceChange = (workspaceId: string) => {
if (workspaceId === currentWorkspaceId) return;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
router.push(`/workspaces/${workspaceId}/`);
});
};
const handleAddProject = () => {
if (projects.length >= organizationProjectsLimit) {
const handleAddWorkspace = () => {
if (workspaces.length >= organizationWorkspacesLimit) {
setOpenLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
setOpenCreateWorkspaceModal(true);
};
const handleProjectSettingsNavigation = (settingId: string) => {
const handleWorkspaceSettingsNavigation = (settingId: string) => {
startTransition(() => {
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
});
@@ -191,17 +191,17 @@ export const ProjectBreadcrumb = ({
];
};
return (
<BreadcrumbItem isActive={isProjectDropdownOpen}>
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
<BreadcrumbItem isActive={isWorkspaceDropdownOpen}>
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="projectDropdownTrigger"
id="workspaceDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{projectName}</span>
<span>{workspaceName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
{isEnvironmentBreadcrumbVisible && !isWorkspaceDropdownOpen ? (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
@@ -214,32 +214,32 @@ export const ProjectBreadcrumb = ({
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
</div>
{isLoadingProjects && (
{isLoadingWorkspaces && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects && loadError && (
{!isLoadingWorkspaces && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
onClick={() => {
setLoadError(null);
setProjects([]);
setWorkspaces([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingProjects && !loadError && (
{!isLoadingWorkspaces && !loadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
{workspaces.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === currentProjectId}
onClick={() => handleProjectChange(proj.id)}
checked={proj.id === currentWorkspaceId}
onClick={() => handleWorkspaceChange(proj.id)}
className="cursor-pointer">
<div className="flex items-center gap-2">
<span>{proj.name}</span>
@@ -249,7 +249,7 @@ export const ProjectBreadcrumb = ({
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleAddProject}
onClick={handleAddWorkspace}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
@@ -263,11 +263,11 @@ export const ProjectBreadcrumb = ({
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
{workspaceSettings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleProjectSettingsNavigation(setting.id)}
checked={isActiveWorkspaceSetting(pathname, setting.id)}
onClick={() => handleWorkspaceSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
@@ -277,17 +277,17 @@ export const ProjectBreadcrumb = ({
</DropdownMenu>
{/* Modals */}
{openLimitModal && (
<ProjectLimitModal
<WorkspaceLimitModal
open={openLimitModal}
setOpen={setOpenLimitModal}
buttons={LimitModalButtons()}
projectLimit={organizationProjectsLimit}
workspaceLimit={organizationWorkspacesLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
{openCreateWorkspaceModal && (
<CreateWorkspaceModal
open={openCreateWorkspaceModal}
setOpen={setOpenCreateWorkspaceModal}
organizationId={currentOrganizationId}
isAccessControlAllowed={isAccessControlAllowed}
/>

View File

@@ -3,11 +3,11 @@
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TWorkspace } from "@formbricks/types/workspace";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
workspace: TWorkspace;
organization: TOrganization;
organizationId: string;
}
@@ -22,12 +22,12 @@ export const useEnvironment = () => {
return context;
};
export const useProject = () => {
export const useWorkspace = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { project: null };
return { workspace: null };
}
return { project: context.project };
return { workspace: context.workspace };
};
export const useOrganization = () => {
@@ -41,25 +41,25 @@ export const useOrganization = () => {
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
project: TProject;
workspace: TWorkspace;
organization: TOrganization;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
project,
workspace,
organization,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
() => ({
environment,
project,
workspace,
organization,
organizationId: project.organizationId,
organizationId: workspace.organizationId,
}),
[environment, project, organization]
[environment, workspace, organization]
);
return (

View File

@@ -27,7 +27,7 @@ const EnvLayout = async (props: {
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
workspace={layoutData.workspace}
organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper>

View File

@@ -3,18 +3,18 @@ import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { getProjectsByUserId } from "./project";
import { getWorkspacesByUserId } from "./workspace";
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
workspace: {
findMany: vi.fn(),
},
},
}));
describe("Project", () => {
describe("getUserProjects", () => {
describe("Workspace", () => {
describe("getUserWorkspaces", () => {
const mockAdminMembership: TMembership = {
role: "manager",
organizationId: "org1",
@@ -29,17 +29,17 @@ describe("Project", () => {
accepted: true,
};
test("should return projects for admin role", async () => {
const mockProjects = [
{ id: "project1", name: "Project 1" },
{ id: "project2", name: "Project 2" },
test("should return workspaces for admin role", async () => {
const mockWorkspaces = [
{ id: "workspace1", name: "Workspace 1" },
{ id: "workspace2", name: "Workspace 2" },
];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
const result = await getProjectsByUserId("user1", mockAdminMembership);
const result = await getWorkspacesByUserId("user1", mockAdminMembership);
expect(prisma.project.findMany).toHaveBeenCalledWith({
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org1",
},
@@ -48,20 +48,20 @@ describe("Project", () => {
name: true,
},
});
expect(result).toEqual(mockProjects);
expect(result).toEqual(mockWorkspaces);
});
test("should return projects for member role with team restrictions", async () => {
const mockProjects = [{ id: "project1", name: "Project 1" }];
test("should return workspaces for member role with team restrictions", async () => {
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
const result = await getProjectsByUserId("user1", mockMemberMembership);
const result = await getWorkspacesByUserId("user1", mockMemberMembership);
expect(prisma.project.findMany).toHaveBeenCalledWith({
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org1",
projectTeams: {
workspaceTeams: {
some: {
team: {
teamUsers: {
@@ -78,13 +78,13 @@ describe("Project", () => {
name: true,
},
});
expect(result).toEqual(mockProjects);
expect(result).toEqual(mockWorkspaces);
});
test("should return empty array when no projects found", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
test("should return empty array when no workspaces found", async () => {
vi.mocked(prisma.workspace.findMany).mockResolvedValue([]);
const result = await getProjectsByUserId("user1", mockAdminMembership);
const result = await getWorkspacesByUserId("user1", mockAdminMembership);
expect(result).toEqual([]);
});
@@ -95,27 +95,27 @@ describe("Project", () => {
clientVersion: "5.0.0",
});
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
vi.mocked(prisma.workspace.findMany).mockRejectedValue(prismaError);
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(
new DatabaseError("Database error")
);
});
test("should re-throw unknown errors", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.project.findMany).mockRejectedValue(unknownError);
vi.mocked(prisma.workspace.findMany).mockRejectedValue(unknownError);
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
});
test("should validate inputs correctly", async () => {
await expect(getProjectsByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
await expect(getWorkspacesByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
});
test("should validate membership input correctly", async () => {
const invalidMembership = {} as TMembership;
await expect(getProjectsByUserId("user1", invalidMembership)).rejects.toThrow();
await expect(getWorkspacesByUserId("user1", invalidMembership)).rejects.toThrow();
});
test("should handle owner role like manager", async () => {
@@ -126,12 +126,12 @@ describe("Project", () => {
accepted: true,
};
const mockProjects = [{ id: "project1", name: "Project 1" }];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }];
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
const result = await getProjectsByUserId("user1", mockOwnerMembership);
const result = await getWorkspacesByUserId("user1", mockOwnerMembership);
expect(prisma.project.findMany).toHaveBeenCalledWith({
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org1",
},
@@ -140,7 +140,7 @@ describe("Project", () => {
name: true,
},
});
expect(result).toEqual(mockProjects);
expect(result).toEqual(mockWorkspaces);
});
});
});

View File

@@ -6,15 +6,15 @@ import { DatabaseError } from "@formbricks/types/errors";
import { TMembership, ZMembership } from "@formbricks/types/memberships";
import { validateInputs } from "@/lib/utils/validate";
export const getProjectsByUserId = reactCache(
export const getWorkspacesByUserId = reactCache(
async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => {
validateInputs([userId, ZString], [orgMembership, ZMembership]);
let projectWhereClause: Prisma.ProjectWhereInput = {};
let workspaceWhereClause: Prisma.WorkspaceWhereInput = {};
if (orgMembership.role === "member") {
projectWhereClause = {
projectTeams: {
workspaceWhereClause = {
workspaceTeams: {
some: {
team: {
teamUsers: {
@@ -29,17 +29,17 @@ export const getProjectsByUserId = reactCache(
}
try {
const projects = await prisma.project.findMany({
const workspaces = await prisma.workspace.findMany({
where: {
organizationId: orgMembership.organizationId,
...projectWhereClause,
...workspaceWhereClause,
},
select: {
id: true,
name: true,
},
});
return projects;
return workspaces;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);

View File

@@ -4,7 +4,7 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/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 { session, organization } = await getEnvironmentAuth(params.environmentId);

View File

@@ -1,31 +1,35 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
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 { children } = props;
const t = await getTranslate();
const [organization, project, session] = await Promise.all([
const [organization, workspace, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getWorkspaceByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!project) {
if (!workspace) {
throw new Error(t("common.workspace_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
return <>{children}</>;

View File

@@ -4,7 +4,6 @@ import { z } from "zod";
import { ZUserNotificationSettings } from "@formbricks/types/user";
import { getUser, updateUser } from "@/lib/user/service";
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";
const ZUpdateNotificationSettingsAction = z.object({
@@ -14,24 +13,14 @@ const ZUpdateNotificationSettingsAction = z.object({
export const updateNotificationSettingsAction = authenticatedActionClient
.inputSchema(ZUpdateNotificationSettingsAction)
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
})
);

View File

@@ -40,7 +40,7 @@ export const EditAlerts = ({
{t("environments.settings.notifications.auto_subscribe_to_new_surveys")}
</p>
<NotificationSwitch
surveyOrProjectOrOrganizationId={membership.organization.id}
surveyOrWorkspaceOrOrganizationId={membership.organization.id}
notificationSettings={user.notificationSettings!}
notificationType={"unsubscribedOrganizationIds"}
autoDisableNotificationType={autoDisableNotificationType}
@@ -66,13 +66,13 @@ export const EditAlerts = ({
</TooltipProvider>
</div>
{membership.organization.projects.some((project) =>
project.environments.some((environment) => environment.surveys.length > 0)
{membership.organization.workspaces.some((workspace) =>
workspace.environments.some((environment) => environment.surveys.length > 0)
) ? (
<div className="grid-cols-8 space-y-1 p-2">
{membership.organization.projects.map((project) => (
<div key={project.id}>
{project.environments.map((environment) => (
{membership.organization.workspaces.map((workspace) => (
<div key={workspace.id}>
{workspace.environments.map((environment) => (
<div key={environment.id}>
{environment.surveys.map((survey) => (
<div
@@ -80,11 +80,11 @@ export const EditAlerts = ({
key={survey.name}>
<div className="col-span-2 text-left">
<div className="font-medium text-slate-900">{survey.name}</div>
<div className="text-xs text-slate-400">{project.name}</div>
<div className="text-xs text-slate-400">{workspace.name}</div>
</div>
<div className="col-span-1 text-center">
<NotificationSwitch
surveyOrProjectOrOrganizationId={survey.id}
surveyOrWorkspaceOrOrganizationId={survey.id}
notificationSettings={user.notificationSettings!}
notificationType={"alert"}
autoDisableNotificationType={autoDisableNotificationType}

View File

@@ -10,7 +10,7 @@ import { Switch } from "@/modules/ui/components/switch";
import { updateNotificationSettingsAction } from "../actions";
interface NotificationSwitchProps {
surveyOrProjectOrOrganizationId: string;
surveyOrWorkspaceOrOrganizationId: string;
notificationSettings: TUserNotificationSettings;
notificationType: "alert" | "unsubscribedOrganizationIds";
autoDisableNotificationType?: string;
@@ -18,7 +18,7 @@ interface NotificationSwitchProps {
}
export const NotificationSwitch = ({
surveyOrProjectOrOrganizationId,
surveyOrWorkspaceOrOrganizationId,
notificationSettings,
notificationType,
autoDisableNotificationType,
@@ -29,8 +29,8 @@ export const NotificationSwitch = ({
const router = useRouter();
const isChecked =
notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true;
const handleSwitchChange = async () => {
setIsLoading(true);
@@ -38,21 +38,21 @@ export const NotificationSwitch = ({
let updatedNotificationSettings = { ...notificationSettings };
if (notificationType === "unsubscribedOrganizationIds") {
const unsubscribedOrganizationIds = updatedNotificationSettings.unsubscribedOrganizationIds ?? [];
if (unsubscribedOrganizationIds.includes(surveyOrProjectOrOrganizationId)) {
if (unsubscribedOrganizationIds.includes(surveyOrWorkspaceOrOrganizationId)) {
updatedNotificationSettings.unsubscribedOrganizationIds = unsubscribedOrganizationIds.filter(
(id) => id !== surveyOrProjectOrOrganizationId
(id) => id !== surveyOrWorkspaceOrOrganizationId
);
} else {
updatedNotificationSettings.unsubscribedOrganizationIds = [
...unsubscribedOrganizationIds,
surveyOrProjectOrOrganizationId,
surveyOrWorkspaceOrOrganizationId,
];
}
} else {
updatedNotificationSettings[notificationType] = {
...updatedNotificationSettings[notificationType],
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
[surveyOrWorkspaceOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId],
};
}
@@ -76,12 +76,12 @@ export const NotificationSwitch = ({
useEffect(() => {
if (
autoDisableNotificationType &&
autoDisableNotificationElementId === surveyOrProjectOrOrganizationId &&
autoDisableNotificationElementId === surveyOrWorkspaceOrOrganizationId &&
isChecked
) {
switch (notificationType) {
case "alert":
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
if (notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true) {
handleSwitchChange();
toast.success(
t(
@@ -95,7 +95,9 @@ export const NotificationSwitch = ({
break;
case "unsubscribedOrganizationIds":
if (!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)) {
if (
!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
) {
handleSwitchChange();
toast.success(
t(

View File

@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
@@ -16,17 +17,18 @@ const setCompleteNotificationSettings = (
notificationSettings: TUserNotificationSettings,
memberships: Membership[]
): TUserNotificationSettings => {
const newNotificationSettings = {
alert: {},
const newNotificationSettings: TUserNotificationSettings = {
alert: {} as Record<string, boolean>,
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
};
for (const membership of memberships) {
for (const project of membership.organization.projects) {
for (const workspace of membership.organization.workspaces) {
// set default values for alerts
for (const environment of project.environments) {
for (const environment of workspace.environments) {
for (const survey of environment.surveys) {
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]) ||
false; // check for legacy notification settings w/o "alerts" key
}
@@ -45,17 +47,17 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
},
OR: [
{
// Fetch all projects if user role is owner or manager
// Fetch all workspaces if user role is owner or manager
role: {
in: ["owner", "manager"],
},
},
{
// Filter projects based on team membership if user is not owner or manager
// Filter workspaces based on team membership if user is not owner or manager
organization: {
projects: {
workspaces: {
some: {
projectTeams: {
workspaceTeams: {
some: {
team: {
teamUsers: {
@@ -77,12 +79,12 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
select: {
id: true,
name: true,
projects: {
workspaces: {
// Apply conditional filtering based on user's role
where: {
OR: [
{
// Fetch all projects if user is owner or manager
// Fetch all workspaces if user is owner or manager
organization: {
memberships: {
some: {
@@ -95,8 +97,8 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
},
},
{
// Only include projects accessible through teams if user is not owner or manager
projectTeams: {
// Only include workspaces accessible through teams if user is not owner or manager
workspaceTeams: {
some: {
team: {
teamUsers: {
@@ -136,24 +138,27 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
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 params = await props.params;
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const autoDisableNotificationType = searchParams["type"];
const autoDisableNotificationElementId = searchParams["elementId"];
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!memberships) {
throw new Error(t("common.membership_not_found"));
throw new ResourceNotFoundError(t("common.membership"), null);
}
if (user?.notificationSettings) {

View File

@@ -4,7 +4,7 @@ export interface Membership {
organization: {
id: string;
name: string;
projects: {
workspaces: {
id: string;
name: string;
environments: {

View File

@@ -20,7 +20,7 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
return {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
@@ -64,49 +64,35 @@ async function handleEmailUpdate({
}
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
)
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
})
);
export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging(
"passwordReset",
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
)
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
})
);

View File

@@ -1,3 +1,4 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
@@ -28,7 +29,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user ? await getUser(session.user.id) : null;
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
@@ -60,7 +61,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
buttons={[
{
text: IS_FORMBRICKS_CLOUD
? t("common.start_free_trial")
? t("common.upgrade_plan")
: t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${params.environmentId}/settings/billing`

View File

@@ -40,6 +40,13 @@ export const OrganizationSettingsNavbar = ({
href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"),
},
{
id: "feedback-record-directories",
label: t("environments.settings.feedback_record_directories.nav_label"),
href: `/environments/${environmentId}/settings/feedback-record-directories`,
current: pathname?.includes("/feedback-record-directories"),
hidden: isMember,
},
{
id: "api-keys",
label: t("common.api_keys"),

View File

@@ -14,7 +14,7 @@ interface SurveyWithSlug {
environment: {
id: string;
type: "production" | "development";
project: {
workspace: {
id: string;
name: string;
};
@@ -40,7 +40,7 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
},
{
label: t("environments.settings.domain.workspace"),
key: "project",
key: "workspace",
},
{
label: t("environments.settings.domain.pretty_url"),
@@ -81,7 +81,7 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
{survey.name}
</Link>
</TableCell>
<TableCell>{survey.environment.project.name}</TableCell>
<TableCell>{survey.environment.workspace.name}</TableCell>
<TableCell>
<IdBadge id={survey.slug ?? ""} />
</TableCell>

View File

@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
@@ -25,10 +26,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
);
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const isOwnerOrManager = isManager || isOwner;
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);

View File

@@ -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: "workspaces",
labelKey: t("environments.settings.enterprise.license_feature_workspaces"),
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>
);
};

View File

@@ -6,22 +6,23 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
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";
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
interface EnterpriseLicenseStatusProps {
status: LicenseStatus;
status: TLicenseStatus;
lastChecked: Date;
gracePeriodEnd?: Date;
environmentId: string;
}
const getBadgeConfig = (
status: LicenseStatus,
status: TLicenseStatus,
t: TFunction
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
switch (status) {
@@ -29,6 +30,11 @@ const getBadgeConfig = (
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":
@@ -40,10 +46,12 @@ const getBadgeConfig = (
export const EnterpriseLicenseStatus = ({
status,
lastChecked,
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false);
@@ -59,6 +67,8 @@ export const EnterpriseLicenseStatus = ({
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 {
@@ -86,7 +96,12 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<div className="flex flex-wrap items-center gap-3">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<span className="text-sm text-slate-500">
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
</span>
</div>
</div>
<Button
type="button"
@@ -112,7 +127,7 @@ export const EnterpriseLicenseStatus = ({
<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, {
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
year: "numeric",
month: "short",
day: "numeric",
@@ -128,6 +143,13 @@ export const EnterpriseLicenseStatus = ({
</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

View File

@@ -10,8 +10,9 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
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 t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
@@ -93,15 +94,19 @@ const Page = async (props) => {
/>
</PageHeader>
{hasLicense ? (
<EnterpriseLicenseStatus
status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
<>
<EnterpriseLicenseStatus
status={licenseState.status}
lastChecked={licenseState.lastChecked}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
</>
) : (
<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">

View File

@@ -0,0 +1 @@
export { FeedbackRecordDirectoriesPage as default } from "@/modules/ee/feedback-record-directory/page";

View File

@@ -7,7 +7,6 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -19,36 +18,26 @@ const ZUpdateOrganizationNameAction = z.object({
export const updateOrganizationNameAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationNameAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
})
);
const ZDeleteOrganizationAction = z.object({
@@ -58,33 +47,23 @@ const ZDeleteOrganizationAction = z.object({
export const deleteOrganizationAction = authenticatedActionClient
.inputSchema(ZDeleteOrganizationAction)
.action(
withAuditLogging(
"deleted",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
}
)
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
})
);

View File

@@ -107,7 +107,7 @@ const DeleteOrganizationModal = ({
}: DeleteOrganizationModalProps) => {
const [inputValue, setInputValue] = useState("");
const { t } = useTranslation();
const handleInputChange = (e) => {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};

View File

@@ -61,7 +61,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
toast.error(errorMessage);
}
} catch (err) {
toast.error(`Error: ${err.message}`);
toast.error(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`);
}
};

View File

@@ -26,7 +26,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null;
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;

View File

@@ -1,31 +1,32 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
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 { children } = props;
const t = await getTranslate();
const [organization, project, session] = await Promise.all([
const [organization, workspace, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getWorkspaceByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!project) {
if (!workspace) {
throw new Error(t("common.workspace_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
return <>{children}</>;

View File

@@ -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>;
};

View File

@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/settings/profile`);
};

View File

@@ -8,7 +8,7 @@ import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
import { getSurveySummary } from "./summary/lib/surveySummary";
export const revalidateSurveyIdPath = async (environmentId: string, surveyId: string) => {
@@ -36,9 +36,9 @@ export const getResponsesAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -70,9 +70,9 @@ export const getSurveySummaryAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -98,9 +98,9 @@ export const getResponseCountAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -126,9 +126,9 @@ export const getDisplaysWithContactAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});

View File

@@ -27,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>;
};

View File

@@ -96,8 +96,8 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns
const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
);
// Save settings to localStorage when they change
@@ -205,11 +205,11 @@ export const ResponseTable = ({
};
// Handle downloading selected responses
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
const downloadSelectedRows = async (responseIds: string[], format: "xlsx" | "csv") => {
try {
const downloadResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: format,
format,
filterCriteria: { responseIds },
});
@@ -300,7 +300,6 @@ export const ResponseTable = ({
<DataTableSettingsModal
open={isTableSettingsModalOpen}
setOpen={setIsTableSettingsModalOpen}
survey={survey}
table={table}
columnOrder={columnOrder}
handleDragEnd={handleDragEnd}

View File

@@ -5,13 +5,14 @@ import { TFunction } from "i18next";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link";
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 { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -34,6 +35,7 @@ const getElementColumnsData = (
element: TSurveyElement,
survey: TSurvey,
isExpanded: boolean,
locale: TUserLocale,
t: TFunction
): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t);
@@ -41,7 +43,7 @@ const getElementColumnsData = (
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
// 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 ElementHeader = () => (
<div className="flex items-center justify-between">
@@ -167,6 +169,7 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -218,6 +221,7 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -232,7 +236,7 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
METADATA_FIELDS.forEach((label) => {
const IconComponent = COLUMNS_ICON_MAP[label];
const IconComponent = COLUMNS_ICON_MAP[label as keyof typeof COLUMNS_ICON_MAP];
metadataColumns.push({
accessorKey: "METADATA_" + label,
@@ -259,11 +263,14 @@ export const generateResponseTableColumns = (
survey: TSurvey,
isExpanded: boolean,
isReadOnly: boolean,
locale: TUserLocale,
t: TFunction,
showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => {
const elements = getElementsFromBlocks(survey.blocks);
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
const elementColumns = elements.flatMap((element) =>
getElementColumnsData(element, survey, isExpanded, locale, t)
);
const dateColumn: ColumnDef<TResponseTableData> = {
accessorKey: "createdAt",
@@ -271,7 +278,7 @@ export const generateResponseTableColumns = (
size: 200,
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
},
};

View File

@@ -1,4 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { TFunction } from "i18next";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -38,7 +39,7 @@ describe("utils", () => {
"environments.surveys.responses.source": "Source",
};
return translations[key] || key;
});
}) as unknown as TFunction;
describe("getAddressFieldLabel", () => {
test("returns correct label for addressLine1", () => {

View File

@@ -80,9 +80,24 @@ export const COLUMNS_ICON_MAP = {
const userAgentFields = ["browser", "os", "device"];
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
if (userAgentFields.includes(label)) {
return meta.userAgent?.[label];
export const getMetadataValue = (
meta: TResponseMeta,
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];
};

View File

@@ -1,3 +1,4 @@
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -7,7 +8,6 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -17,31 +17,30 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { 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 t = await getTranslate();
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(),
getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
if (!survey) {
throw new Error(t("common.survey_not_found"));
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
}
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
@@ -50,10 +49,10 @@ const Page = async (props) => {
const organizationBilling = await getOrganizationBilling(organization.id);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), organization.id);
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
// Fetch initial responses on the server to prevent duplicate client-side fetch
@@ -86,7 +85,7 @@ const Page = async (props) => {
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}
locale={locale}
locale={user.locale}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}

View File

@@ -7,9 +7,8 @@ import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/s
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -36,9 +35,9 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -65,57 +64,49 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
workspaceId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZResetSurveyAction>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "workspaceTeam",
minPermission: "readWrite",
workspaceId,
},
],
});
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
return {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
}
)
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
return {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
})
);
const ZGetEmailHtmlAction = z.object({
@@ -134,9 +125,9 @@ export const getEmailHtmlAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -154,7 +145,8 @@ const ZGeneratePersonalLinksAction = z.object({
export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
}
@@ -168,8 +160,8 @@ export const generatePersonalLinksAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
type: "workspaceTeam",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
@@ -242,8 +234,8 @@ export const updateSingleUseLinksAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
type: "workspaceTeam",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],

View File

@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,13 +32,14 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
};
const renderResponseValue = (value: string) => {
const parsedDate = new Date(value);
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
return (
formattedDate ??
t("common.invalid_date_with_value", {
value,
})
);
};
return (
@@ -59,7 +60,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
elementSummary.samples.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
@@ -84,7 +85,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)}
</div>
<div className="px-4 text-slate-500 md:px-6">
<div className="px-4 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>

View File

@@ -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) {
setFilter(
@@ -104,7 +106,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<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
className="w-full cursor-pointer hover:opacity-80"
key={group}

View File

@@ -15,7 +15,7 @@ interface SummaryMetadataProps {
isQuotasAllowed: boolean;
}
const formatTime = (ttc) => {
const formatTime = (ttc: number) => {
const seconds = ttc / 1000;
let formattedValue;

View File

@@ -105,7 +105,7 @@ export const SummaryPage = ({
setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
toast.error(error);
toast.error(error instanceof Error ? error.message : String(error));
setDisplays([]);
setHasMoreDisplays(false);
} finally {

View File

@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { organizationId, project } = useEnvironment();
const { workspace } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,8 +128,7 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true);
const result = await resetSurveyAction({
surveyId: survey.id,
organizationId: organizationId,
projectId: project.id,
workspaceId: workspace.id,
});
if (result?.data) {
toast.success(
@@ -213,7 +212,7 @@ export const SurveyAnalysisCTA = ({
isFormbricksCloud={isFormbricksCloud}
isReadOnly={isReadOnly}
isStorageConfigured={isStorageConfigured}
projectCustomScripts={project.customHeadScripts}
workspaceCustomScripts={workspace.customHeadScripts}
/>
)}
<SuccessMessage environment={environment} survey={survey} />

View File

@@ -53,7 +53,7 @@ interface ShareSurveyModalProps {
isFormbricksCloud: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
projectCustomScripts?: string | null;
workspaceCustomScripts?: string | null;
}
export const ShareSurveyModal = ({
@@ -68,24 +68,14 @@ export const ShareSurveyModal = ({
isFormbricksCloud,
isReadOnly,
isStorageConfigured,
projectCustomScripts,
workspaceCustomScripts,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
const [showView, setShowView] = useState<ModalView>(modalView);
const { email } = user;
const { t } = useTranslation();
const linkTabs: {
id: ShareViaType | ShareSettingsType;
type: LinkTabsType;
label: string;
icon: React.ElementType;
title: string;
description: string;
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
}[] = useMemo(() => {
const linkTabs = useMemo(() => {
const tabs = [
{
id: ShareViaType.ANON_LINKS,
@@ -203,7 +193,7 @@ export const ShareSurveyModal = ({
title: t("environments.surveys.share.custom_html.nav_title"),
description: t("environments.surveys.share.custom_html.description"),
componentType: CustomHtmlTab,
componentProps: { projectCustomScripts, isReadOnly },
componentProps: { workspaceCustomScripts, isReadOnly },
},
];
@@ -226,7 +216,7 @@ export const ShareSurveyModal = ({
isFormbricksCloud,
email,
isStorageConfigured,
projectCustomScripts,
workspaceCustomScripts,
]);
const getDefaultActiveId = useCallback(() => {

View File

@@ -47,6 +47,7 @@ const createNoCodeConfigType = (t: ReturnType<typeof useTranslation>["t"]) => ({
pageView: t("environments.actions.page_view"),
exitIntent: t("environments.actions.exit_intent"),
fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"),
pageDwell: t("environments.actions.time_on_page"),
});
const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => {
@@ -87,7 +88,7 @@ const DisplayCriteriaItem = ({ icon, title, titleSuffix, description }: DisplayC
export const AppTab = () => {
const { t } = useTranslation();
const { environment, project } = useEnvironment();
const { environment, workspace } = useEnvironment();
const { survey } = useSurvey();
const documentationLinks = useMemo(() => createDocumentationLinks(t), [t]);
@@ -97,8 +98,8 @@ export const AppTab = () => {
if (survey.recontactDays !== null) {
return formatRecontactDaysString(survey.recontactDays, t);
}
if (project.recontactDays !== null) {
return formatRecontactDaysString(project.recontactDays, t);
if (workspace.recontactDays !== null) {
return formatRecontactDaysString(workspace.recontactDays, t);
}
return t("environments.surveys.summary.in_app.display_criteria.time_based_always");
};

View File

@@ -22,7 +22,7 @@ import {
import { TabToggle } from "@/modules/ui/components/tab-toggle";
interface CustomHtmlTabProps {
projectCustomScripts: string | null | undefined;
workspaceCustomScripts: string | null | undefined;
isReadOnly: boolean;
}
@@ -31,7 +31,7 @@ interface CustomHtmlFormData {
customHeadScriptsMode: TSurvey["customHeadScriptsMode"];
}
export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
export const CustomHtmlTab = ({ workspaceCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
const { t } = useTranslation();
const { survey } = useSurvey();
const [isSaving, setIsSaving] = useState(false);
@@ -101,18 +101,18 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
</div>
{/* Workspace Scripts Preview */}
{projectCustomScripts && (
{workspaceCustomScripts && (
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<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">
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
{projectCustomScripts}
{workspaceCustomScripts}
</pre>
</div>
</div>
)}
{!projectCustomScripts && (
{!workspaceCustomScripts && (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3">
<p className="text-sm text-slate-500">
{t("environments.surveys.share.custom_html.no_workspace_scripts")}

View File

@@ -165,7 +165,7 @@ export const PersonalLinksTab = ({
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
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
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",

View File

@@ -39,7 +39,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
}
}
} catch (error) {
logger.error("Failed to generate QR code:", error);
logger.error(error as Error, "Failed to generate QR code");
setHasError(true);
} finally {
setIsLoading(false);
@@ -66,7 +66,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
downloadInstance.download({ name: "survey-qr-code", extension: "png" });
toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon"));
} 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"));
} finally {
setIsDownloading(false);

View File

@@ -4,6 +4,10 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
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 { Badge } from "@/modules/ui/components/badge";
@@ -13,9 +17,9 @@ interface SuccessViewProps {
publicDomain: string;
setSurveyUrl: (url: string) => void;
user: TUser;
tabs: { id: string; label: string; icon: React.ElementType }[];
handleViewChange: (view: string) => void;
handleEmbedViewWithTab: (tabId: string) => void;
tabs: { id: ShareViaType | ShareSettingsType; label: string; icon: React.ElementType }[];
handleViewChange: (view: "start" | "share") => void;
handleEmbedViewWithTab: (tabId: ShareViaType | ShareSettingsType) => void;
isReadOnly: boolean;
}

View File

@@ -1,7 +1,8 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
@@ -9,14 +10,14 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
const t = await getTranslate();
const survey = await getSurvey(surveyId);
if (!survey) {
throw new Error("Survey not found");
throw new ResourceNotFoundError(t("common.survey"), surveyId);
}
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
const workspace = await getWorkspaceByEnvironmentId(survey.environmentId);
if (!workspace) {
throw new Error("Workspace not found");
}
const styling = getStyling(project, survey);
const styling = getStyling(workspace, survey);
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =

View File

@@ -96,7 +96,7 @@ describe("Tests for getQuotasSummary service", () => {
_count: {
quotaLinks: 0,
},
},
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
]);
const result = await getQuotasSummary(surveyId);
@@ -120,7 +120,7 @@ describe("Tests for getQuotasSummary service", () => {
_count: {
quotaLinks: 0,
},
},
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
]);
const result = await getQuotasSummary(surveyId);

View File

@@ -2,17 +2,16 @@ import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TLanguage } from "@formbricks/types/project";
import { TResponseFilterCriteria } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TLanguage } from "@formbricks/types/workspace";
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import {
getElementSummary,
getResponsesForSummary,
@@ -44,7 +43,7 @@ vi.mock("@/lib/survey/service", () => ({
}));
vi.mock("@/lib/surveyLogic/utils", () => ({
evaluateLogic: vi.fn(),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
@@ -100,7 +99,7 @@ const mockBaseSurvey: TSurvey = {
createdBy: "user_123",
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
workspaceOverwrites: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],
@@ -229,12 +228,6 @@ describe("getSurveySummaryDropOff", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
);
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
vi.mocked(performActions).mockReturnValue({
jumpTarget: undefined,
requiredElementIds: [],
calculations: {},
});
});
test("calculates dropOff correctly with welcome card disabled", () => {
@@ -246,7 +239,7 @@ describe("getSurveySummaryDropOff", () => {
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10 },
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it
finished: false,
}, // Dropped at q2
{
@@ -269,22 +262,55 @@ describe("getSurveySummaryDropOff", () => {
);
expect(dropOff.length).toBe(2);
// Q1
// Q1: welcome card disabled so impressions = displayCount
expect(dropOff[0].elementId).toBe("q1");
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
expect(dropOff[0].impressions).toBe(displayCount);
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
expect(dropOff[0].ttc).toBe(10);
// Q2
// Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2)
expect(dropOff[1].elementId).toBe("q2");
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(10);
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
});
test("handles logic jumps", () => {
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
const surveyWithWelcome: TSurvey = {
...surveyWithBlocks,
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
};
const responses = [
{
id: "r1",
data: { q1: "a" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10 }, // Only saw q1, never reached q2
finished: false,
},
] as any;
const displayCount = 1;
const dropOff = getSurveySummaryDropOff(
surveyWithWelcome,
getElementsFromBlocks(surveyWithWelcome.blocks),
responses,
displayCount
);
expect(dropOff[0].impressions).toBe(1); // Saw q1
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
expect(dropOff[1].impressions).toBe(0); // Never saw q2
expect(dropOff[1].dropOffCount).toBe(0);
});
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
const surveyWithLogic: TSurvey = {
...mockBaseSurvey,
blocks: [
@@ -315,36 +341,6 @@ describe("getSurveySummaryDropOff", () => {
charLimit: { enabled: false },
},
] as TSurveyElement[],
logic: [
{
id: "logic1",
conditions: {
id: "condition1",
connector: "and" as const,
conditions: [
{
id: "c1",
leftOperand: {
type: "element" as const,
value: "q2",
},
operator: "equals" as const,
rightOperand: {
type: "static" as const,
value: "b",
},
},
],
},
actions: [
{
id: "action1",
objective: "jumpToBlock" as const,
target: "q4",
},
],
},
],
},
{
id: "block3",
@@ -377,28 +373,21 @@ describe("getSurveySummaryDropOff", () => {
],
questions: [],
};
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
const responses = [
{
id: "r1",
data: { q1: "a", q2: "b" },
data: { q1: "a", q2: "b", q4: "d" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10, q2: 10 },
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic
finished: false,
}, // Jumps from q2 to q4, drops at q4
},
];
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
// Simulate logic on q2 triggering
return data.q2 === "b";
});
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
}
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
});
const dropOff = getSurveySummaryDropOff(
surveyWithLogic,
@@ -407,11 +396,11 @@ describe("getSurveySummaryDropOff", () => {
1
);
expect(dropOff[0].impressions).toBe(1); // q1
expect(dropOff[1].impressions).toBe(1); // q2
expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
expect(dropOff[0].impressions).toBe(1); // q1: seen
expect(dropOff[1].impressions).toBe(1); // q2: seen
expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data)
expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished)
});
});
@@ -662,17 +651,23 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3);
// 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.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
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.avgRanking).toBe(1.5);
// 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.avgRanking).toBe(3);
});
@@ -747,17 +742,23 @@ describe("getQuestionSummary", () => {
expect(summary[0].responseCount).toBe(1);
// 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.avgRanking).toBe(2);
// 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.avgRanking).toBe(1);
// 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.avgRanking).toBe(3);
});
@@ -830,10 +831,12 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3);
// All items should have count 0 and avgRanking 0
(summary[0] as any).choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
});
(summary[0] as any).choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
}
);
});
test("getQuestionSummary handles ranking question with non-array answers", async () => {
@@ -894,10 +897,12 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3);
// All items should have count 0 and avgRanking 0 since we had no valid ranking data
(summary[0] as any).choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
});
(summary[0] as any).choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
}
);
});
test("getQuestionSummary handles ranking question with values not in choices", async () => {
@@ -958,17 +963,23 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3);
// 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.avgRanking).toBe(1);
// 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.avgRanking).toBe(0);
// 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.avgRanking).toBe(3);
});
@@ -986,7 +997,11 @@ describe("getSurveySummary", () => {
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
// which is used by the actual implementation of getResponsesForSummary.
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);
@@ -1020,8 +1035,8 @@ describe("getSurveySummary", () => {
test("handles filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = { finished: true };
const finishedResponses = mockResponses
.filter((r) => r.finished)
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
.filter((r: Record<string, unknown>) => r.finished)
.map((r: Record<string, unknown>) => ({ ...r, contactId: null, personAttributes: {} }));
vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any);
await getSurveySummary(mockSurveyId, filterCriteria);
@@ -1043,7 +1058,11 @@ describe("getResponsesForSummary", () => {
vi.resetAllMocks();
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
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
});
@@ -1842,23 +1861,63 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2);
// 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.columnPercentages).toHaveLength(4); // 4 columns
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
expect(
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
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.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(50);
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
expect(
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
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.columnPercentages.find((col) => col.column === "Poor").percentage).toBe(50);
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
expect(
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 () => {
@@ -1949,19 +2008,48 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(1);
// 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.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
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.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
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.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 () => {
@@ -2055,12 +2143,18 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property
// All rows should have zero responses for all columns
summary[0].data.forEach((row) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col) => {
expect(col.percentage).toBe(0);
});
});
summary[0].data.forEach(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
}
);
});
test("getQuestionSummary handles partial and incomplete matrix responses", async () => {
@@ -2147,22 +2241,59 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2);
// 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.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
expect(
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
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.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
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);
// 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 () => {
@@ -2221,12 +2352,18 @@ describe("Matrix question type tests", () => {
// All rows should have proper structure but zero counts
expect(summary[0].data).toHaveLength(2); // 2 rows
summary[0].data.forEach((row) => {
expect(row.columnPercentages).toHaveLength(2); // 2 columns
expect(row.totalResponsesForRow).toBe(0);
expect(row.columnPercentages[0].percentage).toBe(0);
expect(row.columnPercentages[1].percentage).toBe(0);
});
summary[0].data.forEach(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => {
expect(row.columnPercentages).toHaveLength(2); // 2 columns
expect(row.totalResponsesForRow).toBe(0);
expect(row.columnPercentages[0].percentage).toBe(0);
expect(row.columnPercentages[1].percentage).toBe(0);
}
);
});
test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => {
@@ -2296,21 +2433,46 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(1);
// 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.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
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);
qualityRow.columnPercentages.forEach((col) => {
qualityRow.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
// 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.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 () => {
@@ -2381,17 +2543,48 @@ describe("Matrix question type tests", () => {
expect(summary[0].data).toHaveLength(2); // 2 rows
// 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.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
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);
// Invalid rows should not appear in the summary
expect(summary[0].data.find((row) => row.rowLabel === "InvalidRow")).toBeUndefined();
expect(summary[0].data.find((row) => row.rowLabel === "AnotherInvalidRow")).toBeUndefined();
expect(
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 () => {
@@ -2493,12 +2686,27 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2);
// 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.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
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);
});
@@ -2557,12 +2765,18 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(0); // Counts as response even with null data
// Both rows should have zero responses
summary[0].data.forEach((row) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col) => {
expect(col.percentage).toBe(0);
});
});
summary[0].data.forEach(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
}
);
});
});
@@ -2994,23 +3208,33 @@ describe("Rating question type tests", () => {
expect(summary[0].average).toBe(4.25);
// 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.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.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.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.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.percentage).toBe(0);
@@ -3154,10 +3378,12 @@ describe("Rating question type tests", () => {
expect(summary[0].average).toBe(0);
// Verify all ratings have 0 count and percentage
summary[0].choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
});
summary[0].choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
}
);
// Verify dismissed is 0
expect(summary[0].dismissed.count).toBe(0);
@@ -3232,15 +3458,21 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3
// 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.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.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.percentage).toBe(50);
});
@@ -3311,10 +3543,12 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(0);
// All choices should have zero count
summary[0].choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
});
summary[0].choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
}
);
});
test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => {
@@ -3373,17 +3607,23 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one
// 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.percentage).toBe(100);
// 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.percentage).toBe(0);
// 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();
});
});

View File

@@ -11,14 +11,9 @@ import {
TResponseData,
TResponseFilterCriteria,
TResponseTtc,
TResponseVariables,
ZResponseFilterCriteria,
} from "@formbricks/types/responses";
import {
TSurveyElement,
TSurveyElementChoice,
TSurveyElementTypeEnum,
} from "@formbricks/types/surveys/elements";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyElementSummaryAddress,
@@ -41,8 +36,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { buildWhereClause } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service";
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { convertFloatTo2Decimal } from "./utils";
@@ -97,63 +91,13 @@ export const getSurveySummaryMeta = (
};
};
const evaluateLogicAndGetNextElementId = (
localSurvey: TSurvey,
elements: TSurveyElement[],
data: TResponseData,
localVariables: TResponseVariables,
currentElementIndex: number,
currElementTemp: TSurveyElement,
selectedLanguage: string | null
): {
nextElementId: string | undefined;
updatedSurvey: TSurvey;
updatedVariables: TResponseVariables;
} => {
let updatedSurvey = { ...localSurvey };
let updatedVariables = { ...localVariables };
let firstJumpTarget: string | undefined;
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
if (currentBlock?.logic && currentBlock.logic.length > 0) {
for (const logic of currentBlock.logic) {
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
const { jumpTarget, requiredElementIds, calculations } = performActions(
updatedSurvey,
logic.actions,
data,
updatedVariables
);
if (requiredElementIds.length > 0) {
// Update blocks to mark elements as required
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
...block,
elements: block.elements.map((e) =>
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
),
}));
}
updatedVariables = { ...updatedVariables, ...calculations };
if (jumpTarget && !firstJumpTarget) {
firstJumpTarget = jumpTarget;
}
}
}
}
// If no jump target was set, check for a fallback logic
if (!firstJumpTarget && currentBlock?.logicFallback) {
firstJumpTarget = currentBlock.logicFallback;
}
// Return the first jump target if found, otherwise go to the next element
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
return { nextElementId, updatedSurvey, updatedVariables };
// Determine whether a response interacted with a given element.
// An element was "seen" if the respondent has a ttc entry for it OR provided an answer.
// This is more reliable than replaying survey logic, which can misattribute impressions
// when branching logic skips elements or when partial response data is insufficient
// to evaluate conditions correctly.
const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => {
return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined;
};
export const getSurveySummaryDropOff = (
@@ -174,16 +118,8 @@ export const getSurveySummaryDropOff = (
let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const surveyVariablesData = survey.variables?.reduce(
(acc, variable) => {
acc[variable.id] = variable.value;
return acc;
},
{} as Record<string, string | number>
);
responses.forEach((response) => {
// Calculate total time-to-completion
// Calculate total time-to-completion per element
Object.keys(totalTtc).forEach((elementId) => {
if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId];
@@ -191,51 +127,21 @@ export const getSurveySummaryDropOff = (
}
});
let localSurvey = structuredClone(survey);
let localResponseData: TResponseData = { ...response.data };
let localVariables: TResponseVariables = {
...surveyVariablesData,
};
// Count impressions based on actual interaction data (ttc + response data)
// instead of replaying survey logic which is unreliable with branching
let lastSeenIdx = -1;
let currQuesIdx = 0;
while (currQuesIdx < elements.length) {
const currQues = elements[currQuesIdx];
if (!currQues) break;
// element is not answered and required
if (response.data[currQues.id] === undefined && currQues.required) {
dropOffArr[currQuesIdx]++;
impressionsArr[currQuesIdx]++;
break;
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (wasElementSeen(response, element.id)) {
impressionsArr[i]++;
lastSeenIdx = i;
}
}
impressionsArr[currQuesIdx]++;
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
localSurvey,
elements,
localResponseData,
localVariables,
currQuesIdx,
currQues,
response.language
);
localSurvey = updatedSurvey;
localVariables = updatedVariables;
if (nextElementId) {
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
if (!response.data[nextElementId] && !response.finished) {
dropOffArr[nextQuesIdx]++;
impressionsArr[nextQuesIdx]++;
break;
}
currQuesIdx = nextQuesIdx;
} else {
currQuesIdx++;
}
// Attribute drop-off to the last element the respondent interacted with
if (!response.finished && lastSeenIdx >= 0) {
dropOffArr[lastSeenIdx]++;
}
});
@@ -244,6 +150,8 @@ export const getSurveySummaryDropOff = (
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
});
// When the welcome card is disabled, the first element's impressions should equal displayCount
// because every survey display is an impression of the first element
if (!survey.welcomeCard.enabled) {
dropOffArr[0] = displayCount - impressionsArr[0];
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
@@ -255,7 +163,7 @@ export const getSurveySummaryDropOff = (
impressionsArr[0] = displayCount;
} else {
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0;
}
for (let i = 1; i < elements.length; i++) {
@@ -293,7 +201,10 @@ const checkForI18n = (
) => {
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
let choiceValues = [] as string[];
@@ -318,13 +229,9 @@ const checkForI18n = (
}
// Return the localized value of the choice fo multiSelect single element
if (element && "choices" in element) {
const choice = element.choices?.find(
(choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id]
);
return choice && "label" in choice
? getLocalizedValue(choice.label, "default") || responseData[id]
: responseData[id];
if (element?.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
const choice = element.choices?.find((choice) => choice.label[languageCode] === responseData[id]);
return choice ? getLocalizedValue(choice.label, "default") || responseData[id] : responseData[id];
}
return responseData[id];
@@ -832,13 +739,19 @@ export const getElementSummary = async (
let totalResponseCount = 0;
// Initialize count object
const countMap: Record<string, string> = rows.reduce((acc, row) => {
acc[row] = columns.reduce((colAcc, col) => {
colAcc[col] = 0;
return colAcc;
}, {});
return acc;
}, {});
const countMap: Record<string, Record<string, number>> = rows.reduce(
(acc: Record<string, Record<string, number>>, row) => {
acc[row] = columns.reduce(
(colAcc: Record<string, number>, col) => {
colAcc[col] = 0;
return colAcc;
},
{} as Record<string, number>
);
return acc;
},
{} as Record<string, Record<string, number>>
);
responses.forEach((response) => {
const selectedResponses = response.data[element.id] as Record<string, string>;

View File

@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -32,26 +33,27 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const survey = await getSurvey(params.surveyId);
if (!survey) {
throw new Error(t("common.survey_not_found"));
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), organizationId);
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);

View File

@@ -2,23 +2,16 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
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 { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZGetResponsesDownloadUrlAction = z.object({
@@ -39,9 +32,9 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -77,9 +70,9 @@ export const getSurveyFilterDataAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -89,7 +82,7 @@ export const getSurveyFilterDataAction = authenticatedActionClient
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([
getTagsByEnvironmentId(survey.environmentId),
@@ -99,76 +92,3 @@ export const getSurveyFilterDataAction = authenticatedActionClient
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
});
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param {string} organizationId The ID of the organization to check.
* @returns {Promise<void>} A promise that resolves if the permission is granted.
* @throws {ResourceNotFoundError} If the organization is not found.
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging(
"updated",
"survey",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
}
)
);

View File

@@ -164,12 +164,12 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const datePickerRef = useRef<HTMLDivElement>(null);
const extractMetadataKeys = useCallback((obj, parentKey = "") => {
const extractMetadataKeys = useCallback((obj: Record<string, unknown>, parentKey = "") => {
let keys: string[] = [];
for (let key in obj) {
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 {
keys.push(parentKey + key);
}

View File

@@ -1,6 +1,7 @@
"use client";
import clsx from "clsx";
import { TFunction } from "i18next";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -54,6 +55,25 @@ export enum OptionsType {
QUOTAS = "Quotas",
}
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
switch (type) {
case OptionsType.ELEMENTS:
return t("common.elements");
case OptionsType.TAGS:
return t("common.tags");
case OptionsType.ATTRIBUTES:
return t("common.attributes");
case OptionsType.OTHERS:
return t("common.other_filters");
case OptionsType.META:
return t("common.meta");
case OptionsType.HIDDEN_FIELDS:
return t("common.hidden_fields");
case OptionsType.QUOTAS:
return t("common.quotas");
}
};
export type ElementOption = {
label: string;
elementType?: TSurveyElementTypeEnum;
@@ -113,7 +133,9 @@ const elementIcons = {
};
const getIcon = (type: string) => {
const IconComponent = elementIcons[type];
const IconComponent = (elementIcons as Record<string, (typeof elementIcons)[keyof typeof elementIcons]>)[
type
];
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
};
@@ -216,7 +238,12 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
{options?.map((data) => (
<Fragment key={data.header}>
{data?.option.length > 0 && (
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
<CommandGroup
heading={
<p className="text-sm font-medium text-slate-600">
{getOptionsTypeTranslationKey(data.header, t)}
</p>
}>
{data?.option?.map((o) => (
<CommandItem
key={o.id}

View File

@@ -198,7 +198,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
};
setFilterValue({ ...filterValue });
};
const handleRemoveMultiSelect = (value: string[], index) => {
const handleRemoveMultiSelect = (value: string[], index: number) => {
filterValue.filter[index] = {
...filterValue.filter[index],
filterType: {

View File

@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
Select,
SelectContent,
@@ -14,7 +15,6 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { updateSurveyAction } from "../actions";
interface SurveyStatusDropdownProps {
environment: TEnvironment;
@@ -34,23 +34,27 @@ export const SurveyStatusDropdown = ({
const updateSurveyActionResponse = await updateSurveyAction({ ...survey, status });
if (updateSurveyActionResponse?.data) {
toast.success(
status === "inProgress"
? t("common.survey_live")
: status === "paused"
? t("common.survey_paused")
: status === "completed"
? t("common.survey_completed")
: ""
);
const resultingStatus = updateSurveyActionResponse.data.status;
const statusToToastMessage: Partial<Record<TSurvey["status"], string>> = {
inProgress: t("common.survey_live"),
paused: t("common.survey_paused"),
completed: t("common.survey_completed"),
};
const toastMessage = statusToToastMessage[resultingStatus];
if (toastMessage) {
toast.success(toastMessage);
}
if (updateLocalSurveyStatus) {
updateLocalSurveyStatus(resultingStatus);
}
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
toast.error(errorMessage);
}
if (updateLocalSurveyStatus) updateLocalSurveyStatus(status);
};
return (

View File

@@ -1,4 +1,6 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { SurveyContextWrapper } from "./context/survey-context";
interface SurveyLayoutProps {
@@ -10,9 +12,10 @@ const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => {
const resolvedParams = await params;
const survey = await getSurvey(resolvedParams.surveyId);
const t = await getTranslate();
if (!survey) {
throw new Error("Survey not found");
throw new ResourceNotFoundError(t("common.survey"), resolvedParams.surveyId);
}
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;

View File

@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`);
};

View File

@@ -110,7 +110,7 @@ export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: Work
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="from-brand-light to-brand-dark mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br shadow-md">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
@@ -123,7 +123,7 @@ export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: Work
onChange={(e) => setPromptValue(e.target.value)}
placeholder={t("workflows.placeholder")}
rows={5}
className="focus:border-brand-dark focus:ring-brand-light/20 w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:outline-none focus:ring-2"
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleGenerateWorkflow();
@@ -156,7 +156,7 @@ export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: Work
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
<Sparkles className="text-brand-dark h-6 w-6" />
<Sparkles className="h-6 w-6 text-brand-dark" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
{t("workflows.coming_soon_title")}
@@ -175,7 +175,7 @@ export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: Work
onChange={(e) => setDetailsValue(e.target.value)}
placeholder={t("workflows.follow_up_placeholder")}
rows={4}
className="focus:border-brand-dark focus:ring-brand-light/20 w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:bg-white focus:outline-none focus:ring-2"
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
/>
<div className="mt-4 flex items-center justify-end gap-3">
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">

View File

@@ -2,6 +2,7 @@ import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { WorkflowsPage } from "./components/workflows-page";
@@ -27,11 +28,13 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return redirect("/auth/login");
}
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
return (
<WorkflowsPage
userEmail={user.email}
organizationName={organization.name}
billingPlan={organization.billing.plan}
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
/>
);
};

View File

@@ -1,3 +1,3 @@
import { AppConnectionLoading } from "@/modules/projects/settings/(setup)/app-connection/loading";
import { AppConnectionLoading } from "@/modules/workspaces/settings/(setup)/app-connection/loading";
export default AppConnectionLoading;

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