Compare commits

..

1 Commits

Author SHA1 Message Date
Johannes
53e7d18ee6 research docs & vibed UI 2026-01-19 17:47:27 +00:00
1291 changed files with 33486 additions and 60860 deletions

View File

@@ -150,7 +150,6 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
@@ -185,13 +184,8 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# OTEL_SERVICE_NAME=formbricks
# OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development
# OTEL_TRACES_SAMPLER=parentbased_traceidratio
# OTEL_TRACES_SAMPLER_ARG=1
# OpenTelemetry URL for tracing
# OPENTELEMETRY_LISTENER_URL=http://localhost:4318/v1/traces
# Unsplash API Key
UNSPLASH_ACCESS_KEY=
@@ -231,4 +225,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here
LINGODOTDEV_API_KEY=your_api_key_here

View File

@@ -285,14 +285,12 @@ 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,4 +92,3 @@ 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

@@ -65,8 +65,8 @@ jobs:
set -euo pipefail
echo "Updating Chart.yaml with version: ${VERSION}"
yq -i ".version = \"${VERSION}\"" charts/formbricks/Chart.yaml
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
echo "✅ Successfully updated Chart.yaml"
@@ -77,7 +77,7 @@ jobs:
set -euo pipefail
echo "Packaging Helm chart version: ${VERSION}"
helm package ./charts/formbricks
helm package ./helm-chart
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"

View File

@@ -9,7 +9,6 @@ on:
merge_group:
permissions:
contents: read
pull-requests: read
jobs:
sonarqube:
name: SonarQube
@@ -51,9 +50,6 @@ jobs:
pnpm test:coverage
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
with:
args: >
-Dsonar.verbose=true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@@ -6,9 +6,19 @@ permissions:
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
push:
branches:
- main
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
jobs:
validate-translations:
@@ -22,39 +32,32 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for relevant changes
id: changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
- name: Setup Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
filters: |
translations:
- 'apps/web/**/*.ts'
- 'apps/web/**/*.tsx'
- 'apps/web/locales/**/*.json'
- 'packages/surveys/src/**/*.{ts,tsx}'
- 'packages/surveys/locales/**/*.json'
- 'packages/email/**/*.{ts,tsx}'
node-version: 18
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
- name: Setup pnpm
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
with:
node-version: 22.x
- name: Install pnpm
if: steps.changes.outputs.translations == 'true'
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
version: 9.15.9
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile
- name: Validate translation keys
if: steps.changes.outputs.translations == 'true'
run: pnpm run scan-translations
run: |
echo ""
echo "🔍 Validating translation keys..."
echo ""
pnpm run scan-translations
- name: Skip (no translation-related changes)
if: steps.changes.outputs.translations != 'true'
run: echo "No translation-related files changed — skipping validation."
- name: Summary
if: success()
run: |
echo ""
echo "✅ Translation validation completed successfully!"
echo ""

3
.gitignore vendored
View File

@@ -13,7 +13,6 @@
**/.next/
**/out/
**/build
**/next-env.d.ts
# node
**/dist/
@@ -64,5 +63,3 @@ packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdat
.cursorrules
i18n.cache
stats.html
# next-agents-md
.next-docs/

2
.husky/post-checkout Normal file
View File

@@ -0,0 +1,2 @@
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
prettier --write ./branch.json

View File

@@ -1 +1,40 @@
pnpm lint-staged
# Load environment variables from .env files
if [ -f .env ]; then
set -a
. .env
set +a
fi
pnpm lint-staged
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
if [ -n "$LINGODOTDEV_API_KEY" ]; then
echo ""
echo "🌍 Running Lingo.dev translation workflow..."
echo ""
# Run translation generation and validation
if pnpm run i18n; then
echo ""
echo "✅ Translation validation passed"
echo ""
# Add updated locale files to git
git add apps/web/locales/*.json
else
echo ""
echo "❌ Translation validation failed!"
echo ""
echo "Please fix the translation issues above before committing:"
echo " • Add missing translation keys to your locale files"
echo " • Remove unused translation keys"
echo ""
echo "Or run 'pnpm i18n' to see the detailed report"
echo ""
exit 1
fi
else
echo ""
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
echo " (This is expected for community contributors)"
echo ""
fi

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,417 @@
# Enterprise Feature Access: Status Quo Analysis
## Executive Summary
Formbricks currently uses **two completely different mechanisms** to gate enterprise features depending on deployment type:
| Deployment | Gating Mechanism | Activation | Feature Control |
|------------|------------------|------------|-----------------|
| **Cloud** (`IS_FORMBRICKS_CLOUD=1`) | Billing Plan (`organization.billing.plan`) | Stripe subscription | Plan-based (FREE/STARTUP/CUSTOM) |
| **On-Premise** | License Key (`ENTERPRISE_LICENSE_KEY`) | License API validation | License feature flags |
This dual approach creates **significant complexity**, **code duplication**, and **inconsistent behavior** across the codebase.
---
## 1. Core Architecture
### 1.1 Cloud (Formbricks Cloud)
**Source of Truth:** `organization.billing.plan`
```typescript
// packages/database/zod/organizations.ts
plan: z.enum(["free", "startup", "scale", "enterprise"]).default("free")
```
**Plans and Limits:**
- `FREE`: 3 projects, 1,500 responses/month, 2,000 MIU
- `STARTUP`: 3 projects, 5,000 responses/month, 7,500 MIU
- `CUSTOM`: Unlimited (negotiated limits)
**Activation:** Stripe webhook updates `organization.billing` on checkout/subscription events.
### 1.2 On-Premise (Self-Hosted)
**Source of Truth:** `ENTERPRISE_LICENSE_KEY` environment variable
**License Features Schema:**
```typescript
// apps/web/modules/ee/license-check/types/enterprise-license.ts
{
isMultiOrgEnabled: boolean,
contacts: boolean,
projects: number | null,
whitelabel: boolean,
removeBranding: boolean,
twoFactorAuth: boolean,
sso: boolean,
saml: boolean,
spamProtection: boolean,
ai: boolean,
auditLogs: boolean,
multiLanguageSurveys: boolean,
accessControl: boolean,
quotas: boolean,
}
```
**Activation:** License key validated against `https://ee.formbricks.com/api/licenses/check` (cached for 24h, grace period of 3 days).
---
## 2. Feature Gating Patterns
### 2.1 Pattern A: Dual-Path Check (Most Common)
Features that need **both** Cloud billing **and** on-premise license checks:
```typescript
// apps/web/modules/ee/license-check/lib/utils.ts
const getFeaturePermission = async (billingPlan, featureKey) => {
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD) {
return license.active && billingPlan !== PROJECT_FEATURE_KEYS.FREE;
} else {
return license.active && !!license.features?.[featureKey];
}
};
```
**Used by:**
- `getRemoveBrandingPermission()` - Remove branding
- `getWhiteLabelPermission()` - Whitelabel features
- `getBiggerUploadFileSizePermission()` - Large file uploads
- `getIsSpamProtectionEnabled()` - reCAPTCHA spam protection
- `getMultiLanguagePermission()` - Multi-language surveys
- `getAccessControlPermission()` - Teams & roles
- `getIsQuotasEnabled()` - Quota management
- `getOrganizationProjectsLimit()` - Project limits
### 2.2 Pattern B: License-Only Check
Features checked **only** against license (works same for cloud and on-premise):
```typescript
// apps/web/modules/ee/license-check/lib/utils.ts
const getSpecificFeatureFlag = async (featureKey) => {
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return licenseFeatures[featureKey] ?? false;
};
```
**Used by:**
- `getIsMultiOrgEnabled()` - Multiple organizations
- `getIsContactsEnabled()` - Contacts & segments
- `getIsTwoFactorAuthEnabled()` - 2FA
- `getIsSsoEnabled()` - SSO
- `getIsAuditLogsEnabled()` - Audit logs
### 2.3 Pattern C: Cloud-Only (No License Check)
Features available only on Cloud, gated purely by billing plan:
```typescript
// apps/web/modules/survey/lib/permission.ts
export const getExternalUrlsPermission = async (billingPlan) => {
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
return true; // Always allowed on self-hosted
};
```
**Used by:**
- External URLs permission
- Survey follow-ups (Custom plan only)
### 2.4 Pattern D: On-Premise Only (Disabled on Cloud)
Features explicitly disabled on Cloud:
```typescript
// apps/web/modules/ee/license-check/lib/utils.ts
export const getIsSamlSsoEnabled = async () => {
if (IS_FORMBRICKS_CLOUD) return false; // Never on Cloud
const licenseFeatures = await getLicenseFeatures();
return licenseFeatures.sso && licenseFeatures.saml;
};
```
**Used by:**
- SAML SSO
- Pretty URLs (slug feature)
- Domain/Organization settings page
---
## 3. Files Using Enterprise Features
### 3.1 Core License/Feature Check Files
| File | Purpose |
|------|---------|
| `apps/web/modules/ee/license-check/lib/license.ts` | License fetching & caching |
| `apps/web/modules/ee/license-check/lib/utils.ts` | Permission check functions |
| `apps/web/modules/ee/license-check/types/enterprise-license.ts` | Type definitions |
| `apps/web/lib/constants.ts` | `IS_FORMBRICKS_CLOUD`, `ENTERPRISE_LICENSE_KEY` |
### 3.2 Feature-Specific Implementation Files
#### Remove Branding
- `apps/web/modules/ee/whitelabel/remove-branding/actions.ts`
- `apps/web/modules/ee/whitelabel/remove-branding/components/branding-settings-card.tsx`
- `apps/web/modules/projects/settings/look/page.tsx`
- `apps/web/modules/projects/settings/actions.ts`
#### Whitelabel / Email Customization
- `apps/web/modules/ee/whitelabel/email-customization/actions.ts`
- `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx`
- `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/domain/page.tsx`
#### Multi-Language Surveys
- `apps/web/modules/ee/multi-language-surveys/lib/actions.ts`
- `apps/web/modules/ee/multi-language-surveys/components/*.tsx`
- `apps/web/modules/ee/languages/page.tsx`
#### Contacts & Segments
- `apps/web/modules/ee/contacts/segments/actions.ts`
- `apps/web/modules/ee/contacts/page.tsx`
- `apps/web/modules/ee/contacts/api/v1/**/*.ts`
- `apps/web/modules/ee/contacts/api/v2/**/*.ts`
#### Teams & Access Control
- `apps/web/modules/ee/teams/team-list/components/teams-view.tsx`
- `apps/web/modules/ee/role-management/actions.ts`
- `apps/web/modules/organization/settings/teams/page.tsx`
- `apps/web/modules/organization/settings/teams/actions.ts`
#### SSO / SAML
- `apps/web/modules/ee/sso/lib/sso-handlers.ts`
- `apps/web/modules/ee/auth/saml/api/**/*.ts`
- `apps/web/modules/ee/auth/saml/lib/*.ts`
- `apps/web/modules/auth/lib/authOptions.ts`
#### Two-Factor Authentication
- `apps/web/modules/ee/two-factor-auth/actions.ts`
- `apps/web/modules/ee/two-factor-auth/components/*.tsx`
#### Quotas
- `apps/web/modules/ee/quotas/actions.ts`
- `apps/web/modules/ee/quotas/components/*.tsx`
- `apps/web/modules/ee/quotas/lib/*.ts`
#### Audit Logs
- `apps/web/modules/ee/audit-logs/lib/handler.ts`
- `apps/web/modules/ee/audit-logs/lib/service.ts`
#### Billing (Cloud Only)
- `apps/web/modules/ee/billing/page.tsx`
- `apps/web/modules/ee/billing/api/lib/*.ts`
- `apps/web/modules/ee/billing/components/*.tsx`
### 3.3 API Routes Using Feature Checks
| Route | Feature Check |
|-------|---------------|
| `apps/web/app/api/v1/client/[environmentId]/responses/route.ts` | Spam protection |
| `apps/web/app/api/v2/client/[environmentId]/responses/route.ts` | Spam protection |
| `apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts` | Cloud limits |
| `apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/route.ts` | Contacts |
| `apps/web/modules/api/v2/management/responses/lib/response.ts` | Cloud limits |
### 3.4 UI Pages with Conditional Rendering
| Page | Condition |
|------|-----------|
| `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/` | Cloud only |
| `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx` | On-premise only |
| `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/domain/page.tsx` | On-premise only |
| `apps/web/app/p/[slug]/page.tsx` (Pretty URLs) | On-premise only |
---
## 4. Configuration & Environment Variables
### 4.1 Key Environment Variables
| Variable | Purpose | Default |
|----------|---------|---------|
| `IS_FORMBRICKS_CLOUD` | Enables cloud mode | `"0"` |
| `ENTERPRISE_LICENSE_KEY` | License key for on-premise | (empty) |
| `STRIPE_SECRET_KEY` | Stripe API key (Cloud) | (empty) |
| `AUDIT_LOG_ENABLED` | Enable audit logs | `"0"` |
| `SAML_DATABASE_URL` | SAML configuration DB | (empty) |
### 4.2 Database Schema
```prisma
// Organization billing stored in JSON column
billing: {
stripeCustomerId: string | null,
plan: "free" | "startup" | "scale" | "enterprise",
period: "monthly" | "yearly",
limits: {
projects: number | null,
monthly: {
responses: number | null,
miu: number | null,
}
},
periodStart: Date | null
}
```
---
## 5. Problems with Current Approach
### 5.1 Code Duplication
Almost every feature check function has this pattern:
```typescript
if (IS_FORMBRICKS_CLOUD) {
// Check billing plan
} else {
// Check license feature
}
```
This is repeated in:
- 8+ permission check functions in `utils.ts`
- 30+ files that consume these functions
- Multiple API routes and pages
### 5.2 Inconsistent Feature Gating
| Feature | Cloud Gating | On-Premise Gating |
|---------|--------------|-------------------|
| Remove Branding | `plan !== FREE` | `license.features.removeBranding` |
| Multi-Language | `plan === CUSTOM` OR `license.multiLanguageSurveys` | `license.multiLanguageSurveys` |
| Follow-ups | `plan === CUSTOM` | Always allowed |
| SAML SSO | Never allowed | `license.sso && license.saml` |
| Teams | `plan === CUSTOM` OR `license.accessControl` | `license.accessControl` |
### 5.3 Confusing License Requirement on Cloud
Cloud deployments still require `ENTERPRISE_LICENSE_KEY` to be set for enterprise features to work:
```typescript
// utils.ts - getFeaturePermission
if (IS_FORMBRICKS_CLOUD) {
return license.active && billingPlan !== PROJECT_FEATURE_KEYS.FREE;
// ^^^^^^^^^^^^^^ Still checks license!
}
```
This means Cloud needs **both**:
1. Active billing plan (Stripe subscription)
2. Active enterprise license
### 5.4 Fallback Logic Complexity
```typescript
const featureFlagFallback = async (billingPlan) => {
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD) return license.active && billingPlan === PROJECT_FEATURE_KEYS.CUSTOM;
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};
```
Features have "fallback" behavior for backwards compatibility, adding another layer of complexity.
### 5.5 Testing Complexity
Tests must mock both:
- `IS_FORMBRICKS_CLOUD` constant
- `getEnterpriseLicense()` function
- `organization.billing.plan` in some cases
See: `apps/web/modules/ee/license-check/lib/utils.test.ts` (400+ lines of test mocking)
---
## 6. Feature Availability Matrix
| Feature | Free (Cloud) | Startup (Cloud) | Custom (Cloud) | No License (On-Prem) | License (On-Prem) |
|---------|--------------|-----------------|----------------|---------------------|-------------------|
| Remove Branding | ❌ | ✅ | ✅ | ❌ | ✅* |
| Whitelabel | ❌ | ✅ | ✅ | ❌ | ✅* |
| Multi-Language | ❌ | ❌ | ✅ | ❌ | ✅* |
| Teams & Roles | ❌ | ❌ | ✅ | ❌ | ✅* |
| Contacts | ❌ | ❌ | ❌ | ❌ | ✅* |
| SSO (OIDC) | ❌ | ❌ | ❌ | ❌ | ✅* |
| SAML SSO | ❌ | ❌ | ❌ | ❌ | ✅* |
| 2FA | ❌ | ❌ | ❌ | ❌ | ✅* |
| Audit Logs | ❌ | ❌ | ❌ | ❌ | ✅* |
| Quotas | ❌ | ❌ | ✅ | ❌ | ✅* |
| Spam Protection | ❌ | ❌ | ✅ | ❌ | ✅* |
| Follow-ups | ❌ | ❌ | ✅ | ✅ | ✅ |
| Pretty URLs | ❌ | ❌ | ❌ | ✅ | ✅ |
| Projects Limit | 3 | 3 | Custom | 3 | Custom* |
*Depends on specific license feature flags
---
## 7. Recommendations for Refactoring
### 7.1 Unified Feature Access Layer
Create a single `FeatureAccess` service that abstracts the deployment type:
```typescript
interface FeatureAccessService {
canAccessFeature(feature: FeatureKey, context: AccessContext): Promise<boolean>;
getLimit(limit: LimitKey, context: LimitContext): Promise<number>;
}
```
### 7.2 Normalize Feature Flags
Both Cloud and On-Premise should use the same feature flag schema. Cloud billing plans should map to predefined feature sets.
### 7.3 Remove License Requirement from Cloud
Cloud should not need `ENTERPRISE_LICENSE_KEY`. The license server should be bypassed entirely, with features controlled by billing plan.
### 7.4 Consider Feature Entitlements
Move to an "entitlements" model where:
- Cloud: Stripe subscription metadata defines entitlements
- On-Premise: License API returns entitlements
Both resolve to the same `TFeatureEntitlements` type.
---
## 8. Files That Would Need Changes
### High Priority (Core Logic)
1. `apps/web/modules/ee/license-check/lib/license.ts`
2. `apps/web/modules/ee/license-check/lib/utils.ts`
3. `apps/web/lib/constants.ts`
### Medium Priority (Feature Implementations)
4. All files in `apps/web/modules/ee/*/actions.ts`
5. `apps/web/modules/environments/lib/utils.ts`
6. `apps/web/modules/survey/lib/permission.ts`
7. `apps/web/modules/survey/follow-ups/lib/utils.ts`
### Lower Priority (UI Conditional Rendering)
8. Settings pages with `IS_FORMBRICKS_CLOUD` checks
9. `UpgradePrompt` component usages
10. Navigation components
---
## 9. Summary
The current implementation has **organic complexity** from evolving independently for Cloud and On-Premise deployments. A refactor should:
1. **Unify** the feature access mechanism behind a single interface
2. **Simplify** by removing the dual-check pattern
3. **Normalize** feature definitions across deployment types
4. **Test** with a cleaner mocking strategy
This would reduce the 100+ files touching enterprise features to a single source of truth, making the codebase more maintainable and reducing bugs from inconsistent feature gating.

428
ENTERPRISE_PRD.md Normal file
View File

@@ -0,0 +1,428 @@
I've spent ~2 days iterating over this, setting up Stripe, building our update pricing table, etc. So even though the formatting suggests this to be AI Slob, it's hand-crafted and I've read every line to make sure there is no misleading information 😇
------
### Unified Billing & Feature Access
**Document Version:** 2.1
**Last Updated:** January 17, 2026
**Status:** Ready for development
---
## 1. Executive Summary
Formbricks Cloud needs a unified, Stripe-native approach to billing, feature entitlements, and usage metering. The current implementation has billing logic scattered throughout the codebase, making it difficult to maintain pricing consistency and add new features.
This PRD outlines the requirements for:
1. Using Stripe as the single source of truth for features and billing
2. Implementing usage-based billing with graduated pricing
3. Giving customers control through spending caps
**Scope**: This initiative focuses on Formbricks Cloud. On-Premise licensing will be addressed separately.
---
## 2. Problem Statement
### Current Pain Points
1. **Scattered Billing Logic**: Feature availability is determined by code checks against `organization.billing.plan`, requiring code changes for any pricing adjustment.
2. **Inconsistent Feature Gating**: Different features use different patterns to check access, making it unclear what's available on each plan.
3. **No Usage-Based Billing**: Current plans have hard limits. Customers hitting limits must upgrade to a higher tier even if they only need slightly more capacity.
4. **No Spending Controls**: Customers on usage-based plans have no way to cap their spending.
5. **Manual Usage Tracking**: Response and user counts are tracked locally without integration to billing.
---
## 3. Goals
1. **Stripe as Source of Truth**: All feature entitlements and pricing come from Stripe, not hardcoded in the application.
2. **Usage-Based Billing**: Implement graduated pricing where customers pay for what they use beyond included amounts.
3. **Customer Control**: Allow customers to set spending caps to avoid unexpected charges.
4. **Proactive Communication**: Notify customers as they approach usage limits.
---
## 4. Feature Requirements
### 4.1 Stripe as Single Source of Truth
**Requirement**: The Formbricks instance should not contain billing or pricing logic. All feature availability must be determined by querying Stripe.
**What This Means**:
- No hardcoded plan names or feature mappings in the codebase
- No `if (plan === 'pro')` style checks
- Feature checks query Stripe Entitlements API
- Pricing displayed in UI is fetched from Stripe Products/Prices
- Plan changes take effect immediately via Stripe webhooks
**Benefits**:
- Change pricing without code deployment
- Add new plans without code changes
- A/B test pricing externally
- Single source of truth for sales, support, and product
---
### 4.2 Stripe Entitlements for Feature Access
**Requirement**: Use Stripe's Entitlements API to determine which features each customer can access.
**How It Works**:
1. Define Features in Stripe (see inventory below)
2. Attach Features to Products via ProductFeature
3. When customer subscribes, Stripe creates Active Entitlements
4. Application checks entitlements before enabling features
5. Stripe is already setup correctly with all Products & Features ✅
**Multi-Item Subscriptions Simplify Entitlements**:
- Each plan subscription includes multiple prices (flat fee + metered usage) on the **same Product**
- Since all prices belong to one Product, calling `stripe.entitlements.activeEntitlements.list()` returns all features for that plan automatically
- No need to check multiple products or stitch together entitlements from separate subscriptions
**Feature Inventory (not up-to-date but you get the idea)**:
| Feature Name | Lookup Key | Description |
|--------------|------------|-------------|
| Hide Branding | `hide-branding` | Hide "Powered by Formbricks" |
| API Access | `api-access` | Gates API key generation & API page access |
| Integrations | `integrations` | Gates integrations page & configuration |
| Custom Webhooks | `webhooks` | Webhook integrations |
| Email Follow-ups | `follow-ups` | Automated email follow-ups |
| Custom Links in Surveys | `custom-links-in-surveys` | Custom links within surveys |
| Custom Redirect URL | `custom-redirect-url` | Custom thank-you redirects |
| Two Factor Auth | `two-fa` | 2FA for user accounts |
| Contacts & Segments | `contacts` | Contact management & segmentation |
| Teams & Access Roles | `rbac` | Team-based permissions |
| Quota Management | `quota-management` | Response quota controls |
| Spam Protection | `spam-protection` | reCAPTCHA integration |
| Workspace Limit 1 | `workspace-limit-1` | Up to 1 workspaces |
| Workspace Limit 3 | `workspace-limit-3` | Up to 3 workspaces |
| Workspace Limit 5 | `workspace-limit-5` | Up to 5 workspaces |
<img width="915" height="827" alt="Image" src="https://github.com/user-attachments/assets/1f0e17b5-82c3-475c-9c05-968fdc51e948" />
---
### 4.3 Plan Structure
**Plans & Pricing**:
| Plan | Monthly Price | Annual Price | Savings |
|------|---------------|--------------|---------|
| **Hobby** | Free | Free | — |
| **Pro** | $89/month | $890/year | 2 months free |
| **Scale** | $390/month | $3,900/year | 2 months free |
**Usage Limits**:
| Plan | Workspaces | Responses/mo | Contacts/mo | Overage Billing |
|------|------------|--------------|-------------|-----------------|
| **Hobby** | 1 | 250 | — | No |
| **Pro** | 3 | 2,000 | 5,000 | Yes |
| **Scale** | 5 | 5,000 | 10,000 | Yes |
**Note**: Hobby plan does not include Respondent Identification or Contact Management. Overage billing is only available on Pro and Scale plans.
<img width="1205" height="955" alt="Image" src="https://github.com/user-attachments/assets/047d4097-f7ee-4022-920a-e2cbeb8ceb5d" />
---
### 4.4 Restricted Features (Hobby & Trial Exclusions)
**Requirement**: Certain high-risk features must be excluded from Free (Hobby) plan AND Trial users to prevent fraud and abuse. Other features are included in Trial to maximize conversion.
**Restricted Features (blocked from Hobby + Trial)**:
| Feature | Lookup Key | Abuse Risk | Why Restricted |
|---------|------------|------------|----------------|
| Custom Redirect URL | `custom-redirect-url` | High | Phishing redirects after survey |
| Custom Links in Surveys | `custom-links-in-surveys` | High | Malicious link distribution in survey content |
**Trial-Included Features (to drive conversion)**:
| Feature | Lookup Key | Why Included in Trial |
|---------|------------|----------------------|
| Webhooks | `webhooks` | Low abuse risk, high setup effort = conversion driver |
| API Access | `api-access` | Low abuse risk, high integration value |
| Integrations | `integrations` | Low abuse risk, high integration value |
| Email Follow-ups | `follow-ups` | Requires email verification, monitored |
| Hide Branding | `hide-branding` | No abuse risk, strong conversion driver |
| RBAC | `rbac` | No abuse risk, team adoption driver |
| Spam Protection | `spam-protection` | Actually prevents abuse |
| Quota Management | `quota-management` | Administrative feature |
**Implementation**:
- Restricted features are NOT attached to Hobby or Trial products in Stripe
- Trial includes most Pro/Scale features to maximize value demonstration
- Application checks entitlements via Stripe API - if feature not present, show existing upgrade UI
---
### 4.5 Usage-Based Billing with Graduated Pricing
<img width="1041" height="125" alt="Image" src="https://github.com/user-attachments/assets/f12a56da-89d2-4784-b3c0-6c55dbee85e6" />
**Requirement**: Implement usage-based billing where customers pay a base fee that includes a usage allowance, with flat overage pricing.
**Metrics to Meter**:
| Metric | Event Name | Description |
|--------|------------|-------------|
| **Responses** | `response_created` | Survey responses submitted |
| **Identified Contacts** | `unique_contact_identified` | Unique contacts identified per month |
**Identified Contacts Definition (ON HOLD)**:
An identified contact is one that has been identified in the current billing period via:
- SDK call: `formbricks.setUserId(userId)`
- Personal Survey Link access
- This OUT OF SCOPE for the first iteration to not become a blocker. We can add it if all works end-to-end
**Counting Rules**:
- Each contact identification counts (even if same contact identified multiple times via different methods)
- Same contact re-accessing their personal link = 1 count (same contact)
- Billing period is monthly (even for annual subscribers)
- Meter events sent immediately (real-time)
**Hard Limits via Stripe Metering**:
- Usage is metered through Stripe for billing AND enforcement
- When included usage is exhausted, overage rates apply
- No separate local limit enforcement needed
---
### 4.6 Spending Caps
**Requirement**: Customers must be able to set a maximum monthly spend for usage-based charges.
**Behavior**:
| Cap Setting | Effect |
|-------------|--------|
| No cap (default) | Usage billed without limit |
| Cap with "Warn" | Notifications sent, billing continues |
| Cap with "Pause" | Surveys paused when cap reached |
**Configuration**:
- Minimum spending cap: **$10**
- No grace period when cap is hit
- Immediate pause if "Pause" mode selected
- Stripe does not provide spending caps out of the box, this is something we need to custom develop
**When Cap is Reached (Pause mode)**:
- All surveys for the organization stop collecting responses (needs to be implemented)
- Existing responses are preserved
- In-app banner explains the pause
- Email notification sent to billing contacts
- Owner can lift pause or increase cap
<img width="925" height="501" alt="Image" src="https://github.com/user-attachments/assets/511d1ec6-4550-4aec-8f31-ab68e8c9e383" />
_The Pause vs. Alert mode is missing so far._
---
### 4.7 Usage Alerts via Stripe Meter Alerts
**Requirement**: Proactively notify customers as they approach their included usage limits.
**Alert Thresholds**:
| Threshold | Notification |
|-----------|--------------|
| 80% of included | Email notification |
| 90% of included | Email + in-app banner |
| 100% of included | Email + in-app + (if cap) action |
**Notification Content**:
- Current usage vs included amount
- What happens next (overage pricing applies)
- Link to upgrade or adjust spending cap
<img width="415" height="348" alt="Image" src="https://github.com/user-attachments/assets/7bd990b4-7150-4357-af84-9c5e98f75140" />
---
### 4.8 Annual Billing with Monthly Limits
**Requirement**: Support annual payment option while keeping all usage limits monthly.
**Behavior**:
- Annual subscribers pay upfront for 12 months
- **2 months free** discount (annual = 10x monthly price)
- Usage limits reset monthly (same as monthly subscribers)
- Overage is billed monthly
- Example: Annual Pro pays $890/year, gets 2,000 responses/month every month
<img width="1033" height="429" alt="Image" src="https://github.com/user-attachments/assets/58df55c7-e20f-448c-953d-e62c57268421" />
---
### 4.9 Reverse Trial Experience
**Requirement**: New users should experience premium features immediately through a Reverse Trial model.
**Trigger**: We have UI to present to them to opt into the free trial
**Trial Terms**:
- Duration: 14 days
- Features: Enroll to Trial Product (free)
- Limits: We have to see how to enforce those, gotta check what Stripe API offers us. Probably a dedicated Trial Meter
- No payment required to start
- Stripe customer created immediately (for metering)
**Post-Trial (No Conversion)**:
- Downgrade to Hobby (Free) tier immediately
- Pro features disabled immediately
- Data preserved but locked behind upgrade
---
### 4.10 Stripe Customer Creation on Signup
**Requirement**: Create a Stripe customer immediately when a new organization is created.
**Rationale**:
- Enables usage metering from day one
- Stripe handles hard limits via metering
- Simplifies upgrade flow (customer already exists)
**What Gets Created**:
- Stripe Customer with organization ID in metadata
- No subscription (Hobby tier has no subscription)
- No payment method (added on first upgrade)
---
### 5.1 Subscription Architecture: Multi-Item Subscriptions
**Key Insight**: Each plan uses a **Subscription with Multiple Items** — one flat-fee price and metered usage prices, all belonging to the same Product. This allows us to charge for the base plan, meter and charge per used item.
**How It Works**:
```javascript
// Creating a Pro subscription with flat fee + usage metering
const subscription = await stripe.subscriptions.create({
customer: 'cus_12345',
items: [
{ price: 'price_pro_monthly' }, // $89/mo flat fee
{ price: 'price_pro_responses_usage' }, // Metered responses
{ price: 'price_pro_contacts_usage' }, // Metered contacts
],
});
```
**Why This Matters for Entitlements**:
- All prices belong to the **same Product** (e.g., `prod_ProPlan`)
- Stripe Entitlements API automatically returns all features attached to that Product
- No need to check multiple products or subscriptions
- Single source of truth for feature access
**What Customers See** (Single Invoice):
| Description | Qty | Amount |
|-------------|-----|--------|
| Pro Plan (Jan 1 - Feb 1) | 1 | $89.00 |
| Pro Plan - Responses (Jan 1 - Feb 1) | 1,500 (First 1,000 included) | $40.00 |
| Pro Plan - Contacts (Jan 1 - Feb 1) | 2,500 (All included) | $0.00 |
| **Total** | | **$129.00** |
### 5.2 Products & Prices
Each plan Product contains multiple Prices:
| Product | Stripe ID | Prices |
|---------|-----------|--------|
| **Hobby Tier** | `prod_ToYKB5ESOMZZk5` | Free (no subscription required) |
| **Pro Tier** | `prod_ToYKQ8WxS3ecgf` | `price_pro_monthly` ($89), `price_pro_yearly` ($890), `price_pro_usage_responses`, `price_pro_usage_contacts` |
| **Scale Tier** | `prod_ToYLW5uCQTMa6v` | `price_scale_monthly` ($390), `price_scale_yearly` ($3,900), `price_scale_usage_responses`, `price_scale_usage_contacts` |
| **Trial Tier** | `prod_TodVcJiEnK5ABK` | `price_trial_free` ($0), metered prices for tracking |
**Note**: Response and Contact metered prices use **graduated tiers** where the first N units are included (priced at $0), then overage rates apply.
---
## 6. Non-Functional Requirements
### 6.1 Performance
- Entitlement checks: <100ms (p50), <200ms (p99) with caching
- Usage metering: Non-blocking, immediate send
- Spending cap checks: <50ms
### 6.2 Reliability
- Stripe unavailable: Use cached entitlements (max 5 min stale)
- Meter event fails: Queue for retry (at-least-once delivery)
- Webhook missed: Entitlements auto-refresh on access
### 6.3 Data Consistency
- Stripe is source of truth
- Local `organization.billing` is a cache only
- Cache invalidated via webhooks
---
## 7. Migration Considerations
### Existing Customers with Custom Limits
**Problem**: Some existing customers have negotiated custom limits that don't fit the new plan structure.
**Approach**: Grandfather indefinitely on legacy pricing until they choose to migrate.
- Existing customers keep their current plans and limits
- No forced migration
- New billing system only applies to new signups and customers who voluntarily upgrade/change plans
- Legacy customers get a simplified view with the new usage meters UI and a the "Manage subscription" button. We hide all of the other UI and prompt them to reach out to Support to change their pricing (Alert component)
---
## 8. Key Decisions
| Topic | Decision |
|-------|----------|
| Free tier metering | Use Stripe for hard limits (no local enforcement) |
| Annual discount | 2 months free |
| Minimum spending cap | $10 |
| Cap grace period | None (immediate) |
| Contact identification counting | Each identification counts |
| Personal link re-access | Same contact = 1 count |
| Downgrade behavior for restricted features | Immediate disable |
| Meter event timing | Immediate (real-time) |
| Currency | USD only |
| Default spending cap | No cap |
| Overage visibility | Billing page |
| Migration for custom limits | Grandfather indefinitely |
---
## 9. Out of Scope
1. **On-Premise licensing**: Will be addressed separately
2. **Self-serve downgrade**: Handled via Stripe Customer Portal
3. **Refunds**: Handled via Stripe Dashboard
4. **Tax calculation**: Handled by Stripe Tax
5. **Invoice customization**: Handled via Stripe settings
---
## 10. Setting up
**Stripe** Sandbox Cloud: Dev
<img width="300" height="180" alt="Image" src="https://github.com/user-attachments/assets/3e6a7fb6-8efb-4cb5-acf0-ccc2a5efabd2" />
In this branch you find;
- All of the dummy UI screenshotted here. Make sure to clean up after it was successfully implemented (has dummy UI code)
- A comprehensive analysis of our current, inconsistent feature flagging called ENTERPRISE_FEATURE_ANALYSIS

View File

@@ -10,20 +10,25 @@
"build-storybook": "storybook build",
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"@formbricks/survey-ui": "workspace:*"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.17",
"@storybook/addon-links": "10.2.17",
"@storybook/addon-onboarding": "10.2.17",
"@storybook/react-vite": "10.2.17",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.57.0",
"@vitejs/plugin-react": "5.1.4",
"@chromatic-com/storybook": "^5.0.0",
"@storybook/addon-a11y": "10.1.11",
"@storybook/addon-links": "10.1.11",
"@storybook/addon-onboarding": "10.1.11",
"@storybook/react-vite": "10.1.11",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@tailwindcss/vite": "4.1.18",
"@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
"esbuild": "0.27.2",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"eslint-plugin-storybook": "10.1.11",
"prop-types": "15.8.1",
"storybook": "10.1.11",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.17"
"@storybook/addon-docs": "10.1.11"
}
}

View File

@@ -1,4 +1,20 @@
module.exports = {
extends: ["@formbricks/eslint-config/legacy-next.js"],
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
overrides: [
{
files: ["locales/*.json"],
plugins: ["i18n-json"],
rules: {
"i18n-json/identical-keys": [
"error",
{
filePath: require("path").join(__dirname, "locales", "en-US.json"),
checkExtraKeys: false,
checkMissingKeys: true,
},
],
},
},
],
};

View File

@@ -1,6 +0,0 @@
const baseConfig = require("../../.prettierrc.js");
module.exports = {
...baseConfig,
tailwindConfig: "./tailwind.config.js",
};

View File

@@ -1,4 +1,4 @@
FROM node:24-alpine3.23 AS base
FROM node:22-alpine3.22 AS base
#
## step 1: Prune monorepo
@@ -18,9 +18,9 @@ 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
RUN corepack prepare pnpm@9.15.9 --activate
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
@@ -67,18 +67,22 @@ 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...
# Extract Prisma version
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
#
## step 3: setup production runner
#
FROM base AS runner
# 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 apk update && apk upgrade --no-cache \
&& npm install --ignore-scripts -g npm@latest \
RUN npm install --ignore-scripts -g corepack@latest && \
corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
# && addgroup --system --gid 1001 nodejs \
&& addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
@@ -103,56 +107,31 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
# Create packages/database directory structure with proper ownership for runtime migrations
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/dist ./packages/database/dist
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
# Copy prisma client packages
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
COPY --from=installer /prisma_version.txt .
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
# 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
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
# Pino loads transport code in worker threads via dynamic require().
# Next.js file tracing only traces static imports, missing runtime-loaded files
# (e.g. pino/lib/transport-stream.js, transport targets).
# Copy the full packages to ensure all runtime files are available.
COPY --from=installer /app/node_modules/pino ./node_modules/pino
RUN chmod -R 755 ./node_modules/pino
COPY --from=installer /app/node_modules/pino-opentelemetry-transport ./node_modules/pino-opentelemetry-transport
RUN chmod -R 755 ./node_modules/pino-opentelemetry-transport
COPY --from=installer /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
RUN chmod -R 755 ./node_modules/pino-abstract-transport
COPY --from=installer /app/node_modules/otlp-logger ./node_modules/otlp-logger
RUN chmod -R 755 ./node_modules/otlp-logger
# Install prisma CLI globally for database migrations and fix permissions for nextjs user
RUN npm install --ignore-scripts -g prisma@6 \
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
RUN npm install -g prisma@6
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
@@ -162,11 +141,13 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
USER nextjs
# Prepare pnpm as the nextjs user to ensure it's available at runtime
# Prepare volumes for uploads and SAML connections
RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
RUN corepack prepare pnpm@9.15.9 --activate && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
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

@@ -69,7 +69,7 @@ export const ConnectWithFormbricks = ({
) : (
<div className="flex animate-pulse flex-col items-center space-y-4">
<span className="relative flex h-10 w-10">
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span>
<p className="pt-4 text-sm font-medium text-slate-600">

View File

@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel}
/>
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>

View File

@@ -4,10 +4,7 @@ import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
const OnboardingLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const OnboardingLayout = async (props) => {
const params = await props.params;
const { children } = props;

View File

@@ -2,7 +2,6 @@ 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 { replacePresetPlaceholders } from "./utils";
@@ -26,7 +25,7 @@ const mockProject: TProject = {
},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
languages: [],
logo: null,
@@ -40,13 +39,13 @@ const mockTemplate: TXMTemplate = {
elements: [
{
id: "q1",
type: "openText" as TSurveyElementTypeEnum.OpenText,
type: "openText" as const,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
charLimit: { enabled: true, max: 1000 },
charLimit: 1000,
},
],
},

View File

@@ -14,7 +14,7 @@ describe("xm-templates", () => {
});
test("getXMSurveyDefault returns default survey template", () => {
const tMock = vi.fn((key: string) => key) as unknown as TFunction;
const tMock = vi.fn((key) => key) 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: string) => key) as unknown as TFunction;
const tMock = vi.fn((key) => key) 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 unknown as TFunction;
}) as TFunction;
const result = getXMTemplates(tMock);

View File

@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -19,8 +19,8 @@ describe("getTeamsByOrganizationId", () => {
test("returns mapped teams", async () => {
const mockTeams = [
{ 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" },
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
];
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1");

View File

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

View File

@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
return (
<aside
className={cn(
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
"w-sidebar-collapsed z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />

View File

@@ -5,10 +5,7 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const LandingLayout = async (props) => {
const params = await props.params;
const { children } = props;

View File

@@ -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: { params: Promise<{ organizationId: string }> }) => {
const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();

View File

@@ -8,10 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const ProjectOnboardingLayout = async (props) => {
const params = await props.params;
const { children } = props;

View File

@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -8,10 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const OnboardingLayout = async (props) => {
const params = await props.params;
const { children } = props;
@@ -31,10 +28,8 @@ const OnboardingLayout = async (props: {
throw new Error(t("common.organization_not_found"));
}
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
getOrganizationProjectsLimit(organization.id),
getOrganizationProjectsCount(organization.id),
]);
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
return redirect(`/`);

View File

@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -1,4 +1,3 @@
import { getTranslate } from "@/lingodotdev/server";
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
import { Header } from "@/modules/ui/components/header";
@@ -6,17 +5,16 @@ interface SelectPlanOnboardingProps {
organizationId: string;
}
export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboardingProps) => {
const t = await getTranslate();
export const SelectPlanOnboarding = ({ organizationId }: SelectPlanOnboardingProps) => {
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")}
title="Ship professional, unbranded surveys today!"
subtitle="No credit card required, no strings attached."
/>
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
<SelectPlanCard nextUrl={nextUrl} />
</div>
);
};

View File

@@ -1,12 +1,8 @@
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;
@@ -16,6 +12,7 @@ interface PlanPageProps {
const Page = async (props: PlanPageProps) => {
const params = await props.params;
// Only show on Cloud
if (!IS_FORMBRICKS_CLOUD) {
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
}
@@ -26,16 +23,6 @@ const Page = async (props: PlanPageProps) => {
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} />;
};

View File

@@ -3,7 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -17,7 +17,6 @@ import {
import { createProjectAction } 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";
@@ -65,17 +64,10 @@ export const ProjectSettings = ({
const { t } = useTranslation();
const addProject = async (data: TProjectUpdateInput) => {
try {
// Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted.
// Without this, only brandColor is saved and the look-and-feel page falls
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createProjectResponse = await createProjectAction({
organizationId,
data: {
...data,
styling: fullStyling,
config: { channel, industry },
teamIds: data.teamIds,
},
@@ -120,7 +112,6 @@ export const ProjectSettings = ({
const projectName = form.watch("name");
const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
const { isSubmitting } = form.formState;
const organizationTeamsOptions = organizationTeams.map((team) => ({
@@ -228,27 +219,29 @@ export const ProjectSettings = ({
</FormProvider>
</div>
<div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow">
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
{logoUrl && (
<Image
src={logoUrl}
alt="Logo"
width={256}
height={56}
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
<p className="text-sm text-slate-400">{t("common.preview")}</p>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}
autoFocus={false}
/>
<div className="z-0 h-3/4 w-3/4">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={{ brandColor: { light: brandColor } }}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}
autoFocus={false}
/>
</div>
</div>
<CreateTeamModal
open={createTeamModalOpen}

View File

@@ -42,7 +42,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/>
{projects.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

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

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
export const ZOrganizationTeam = z.object({
id: z.cuid2(),
id: z.string().cuid2(),
name: z.string(),
});

View File

@@ -2,10 +2,7 @@ import { redirect } from "next/navigation";
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
const SurveyEditorEnvironmentLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const SurveyEditorEnvironmentLayout = async (props) => {
const params = await props.params;
const { children } = props;

View File

@@ -6,26 +6,15 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti";
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
interface ConfirmationPageProps {
environmentId: string;
}
export const ConfirmationPage = () => {
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
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 (
@@ -41,12 +30,7 @@ export const ConfirmationPage = () => {
</p>
</div>
<Button asChild className="w-full justify-center">
<Link
href={
resolvedEnvironmentId
? `/environments/${resolvedEnvironmentId}/settings/billing`
: "/environments"
}>
<Link href={`/environments/${environmentId}/settings/billing`}>
{t("billing_confirmation.back_to_billing_overview")}
</Link>
</Button>

View File

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

View File

@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
@@ -10,6 +10,7 @@ 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 { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getAccessControlPermission,
@@ -24,63 +25,67 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput,
});
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging("created", "project", async ({ ctx, parsedInput }) => {
const { user } = ctx;
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
withAuditLogging(
"created",
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
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"],
},
],
});
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(organizationId);
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
})
)
);
const ZGetOrganizationsForSwitcherAction = z.object({
@@ -92,7 +97,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
* Called on-demand when user opens the organization switcher.
*/
export const getOrganizationsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetOrganizationsForSwitcherAction)
.schema(ZGetOrganizationsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -117,7 +122,7 @@ const ZGetProjectsForSwitcherAction = z.object({
* Called on-demand when user opens the project switcher.
*/
export const getProjectsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetProjectsForSwitcherAction)
.schema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -133,7 +138,7 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
// Need membership for getProjectsByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) {
throw new AuthorizationError("Membership not found");
throw new Error("Membership not found");
}
return await getProjectsByUserId(ctx.user.id, membership);

View File

@@ -29,15 +29,16 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isAccessControlAllowed,
projectPermission,
license,
peopleCount,
responseCount,
} = layoutData;
// Calculate derived values (no queries)
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
const { features, lastChecked, isPendingDowngrade, active, status } = license;
const { features, lastChecked, isPendingDowngrade, active } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members
@@ -51,6 +52,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<LimitsReachedBanner
organization={organization}
environmentId={environment.id}
peopleCount={peopleCount}
responseCount={responseCount}
/>
)}
@@ -61,7 +63,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
active={active}
environmentId={environment.id}
locale={user.locale}
status={status}
/>
<div className="flex h-full">

View File

@@ -11,7 +11,6 @@ import {
RocketIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -28,7 +27,6 @@ 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 { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
@@ -111,26 +109,16 @@ export const MainNavigation = ({
href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/workspace"),
isActive: pathname?.includes("/project"),
},
],
[t, environment.id, pathname, isFormbricksCloud]
[t, environment.id, pathname]
);
const dropdownNavigation = [
@@ -168,20 +156,6 @@ 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 (
@@ -211,7 +185,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -256,13 +230,6 @@ 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

@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon />
</div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon />

View File

@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort organizations by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
// Handle server errors or validation errors

View File

@@ -82,7 +82,7 @@ export const ProjectBreadcrumb = ({
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
// Handle server errors or validation errors

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: { params: Promise<{ environmentId: string }> }) => {
const EnvironmentPage = async (props) => {
const params = await props.params;
const { session, organization } = await getEnvironmentAuth(params.environmentId);

View File

@@ -4,10 +4,7 @@ import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const AccountSettingsLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const AccountSettingsLayout = async (props) => {
const params = await props.params;
const { children } = props;

View File

@@ -4,6 +4,7 @@ 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({
@@ -11,16 +12,26 @@ const ZUpdateNotificationSettingsAction = z.object({
});
export const updateNotificationSettingsAction = authenticatedActionClient
.inputSchema(ZUpdateNotificationSettingsAction)
.schema(ZUpdateNotificationSettingsAction)
.action(
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;
})
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;
}
)
);

View File

@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
const isChecked =
notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => {
setIsLoading(true);
@@ -49,11 +49,8 @@ export const NotificationSwitch = ({
];
}
} else {
updatedNotificationSettings[notificationType] = {
...updatedNotificationSettings[notificationType],
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
};
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
}
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
@@ -81,7 +78,7 @@ export const NotificationSwitch = ({
) {
switch (notificationType) {
case "alert":
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange();
toast.success(
t(

View File

@@ -16,8 +16,8 @@ const setCompleteNotificationSettings = (
notificationSettings: TUserNotificationSettings,
memberships: Membership[]
): TUserNotificationSettings => {
const newNotificationSettings: TUserNotificationSettings = {
alert: {} as Record<string, boolean>,
const newNotificationSettings = {
alert: {},
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
};
for (const membership of memberships) {
@@ -26,8 +26,7 @@ const setCompleteNotificationSettings = (
for (const environment of project.environments) {
for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] =
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
?.responseFinished ||
notificationSettings[survey.id]?.responseFinished ||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
false; // check for legacy notification settings w/o "alerts" key
}
@@ -137,10 +136,7 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
return memberships;
};
const Page = async (props: {
params: Promise<{ environmentId: string }>;
searchParams: Promise<Record<string, string>>;
}) => {
const Page = async (props) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();

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: TUserPersonalInfoUpdateInput): TUserUpdateInput {
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
return {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
@@ -58,41 +58,55 @@ async function handleEmailUpdate({
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail, ctx.user.locale);
await sendVerificationNewEmail(ctx.user.id, inputEmail);
}
return payload;
}
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
export const updateUserAction = authenticatedActionClient.schema(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 });
// 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);
// 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;
}
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 }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
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 };
}
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
})
)
);

View File

@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button";
@@ -198,54 +198,41 @@ export const EditProfileDetailsForm = ({
<FormField
control={form.control}
name="locale"
render={({ field }) => {
const selectedLanguage = appLanguages.find((l) => l.code === field.value);
return (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{selectedLanguage ? (
<>
{selectedLanguage.label["en-US"]}
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
` (${selectedLanguage.label.native})`}
</>
) : (
t("common.select")
)}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{sortedAppLanguages.map((lang) => (
<DropdownMenuRadioItem
key={lang.code}
value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
);
}}
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (
<DropdownMenuRadioItem
key={lang.code}
value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
)}
/>
{isPasswordResetEnabled && (

View File

@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
aria-label="password"
aria-required="true"
required
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>

View File

@@ -28,7 +28,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
throw new Error(t("common.session_not_found"));
}
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const isOwnerOrManager = isManager || isOwner;
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);

View File

@@ -1,155 +0,0 @@
"use client";
import { TFunction } from "i18next";
import { RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { SettingsCard } from "../../../components/SettingsCard";
interface EnterpriseLicenseStatusProps {
status: TLicenseStatus;
gracePeriodEnd?: Date;
environmentId: string;
}
const getBadgeConfig = (
status: TLicenseStatus,
t: TFunction
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
switch (status) {
case "active":
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
case "expired":
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
case "instance_mismatch":
return {
type: "error",
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
};
case "unreachable":
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
case "invalid_license":
return { type: "error", label: t("environments.settings.enterprise.license_status_invalid") };
default:
return { type: "gray", label: t("environments.settings.enterprise.license_status") };
}
};
export const EnterpriseLicenseStatus = ({
status,
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
const { t } = useTranslation();
const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false);
const handleRecheck = async () => {
setIsRechecking(true);
try {
const result = await recheckLicenseAction({ environmentId });
if (result?.serverError) {
toast.error(result.serverError || t("environments.settings.enterprise.recheck_license_failed"));
return;
}
if (result?.data) {
if (result.data.status === "unreachable") {
toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
} else if (result.data.status === "instance_mismatch") {
toast.error(t("environments.settings.enterprise.recheck_license_instance_mismatch"));
} else if (result.data.status === "invalid_license") {
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
} else {
toast.success(t("environments.settings.enterprise.recheck_license_success"));
}
router.refresh();
} else {
toast.error(t("environments.settings.enterprise.recheck_license_failed"));
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("environments.settings.enterprise.recheck_license_failed")
);
} finally {
setIsRechecking(false);
}
};
const badgeConfig = getBadgeConfig(status, t);
return (
<SettingsCard
title={t("environments.settings.enterprise.license_status")}
description={t("environments.settings.enterprise.license_status_description")}>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRecheck}
disabled={isRechecking}
className="shrink-0">
{isRechecking ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4 animate-spin" />
{t("environments.settings.enterprise.rechecking")}
</>
) : (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
{t("environments.settings.enterprise.recheck_license")}
</>
)}
</Button>
</div>
{status === "unreachable" && gracePeriodEnd && (
<Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})}
</AlertDescription>
</Alert>
)}
{status === "invalid_license" && (
<Alert variant="error" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_invalid_description")}
</AlertDescription>
</Alert>
)}
{status === "instance_mismatch" && (
<Alert variant="error" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_instance_mismatch_description")}
</AlertDescription>
</Alert>
)}
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a
className="font-medium text-slate-700 underline hover:text-slate-900"
href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
</p>
</div>
</SettingsCard>
);
};

View File

@@ -2,16 +2,15 @@ import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
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";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
@@ -26,8 +25,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return notFound();
}
const licenseState = await getEnterpriseLicense();
const hasLicense = licenseState.status !== "no-license";
const { active: isEnterpriseEdition } = await getEnterpriseLicense();
const paidFeatures = [
{
@@ -92,22 +90,35 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
activeId="enterprise"
/>
</PageHeader>
{hasLicense ? (
<EnterpriseLicenseStatus
status={licenseState.status}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
{isEnterpriseEdition ? (
<div>
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="space-y-4 p-8">
<div className="flex items-center gap-x-2">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
</div>
<p className="text-slate-800">
{t(
"environments.settings.enterprise.your_enterprise_license_is_active_all_features_unlocked"
)}
</p>
</div>
<p className="text-sm text-slate-500">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a className="font-semibold underline" href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
</p>
</div>
</div>
</div>
) : (
<div>
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg
viewBox="0 0 1024 1024"
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true">
<circle
cx={512}
@@ -142,8 +153,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
{t("environments.settings.enterprise.enterprise_features")}
</h2>
<ul className="my-4 space-y-4">
{paidFeatures.map((feature) => (
<li key={feature.title} className="flex items-center">
{paidFeatures.map((feature, index) => (
<li key={index} className="flex items-center">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
</div>

View File

@@ -7,6 +7,7 @@ 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";
@@ -16,38 +17,49 @@ const ZUpdateOrganizationNameAction = z.object({
});
export const updateOrganizationNameAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationNameAction)
.schema(ZUpdateOrganizationNameAction)
.action(
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;
})
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;
}
)
);
const ZDeleteOrganizationAction = z.object({
organizationId: ZId,
});
export const deleteOrganizationAction = authenticatedActionClient
.inputSchema(ZDeleteOrganizationAction)
.action(
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
export const deleteOrganizationAction = authenticatedActionClient.schema(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");
@@ -65,5 +77,6 @@ export const deleteOrganizationAction = authenticatedActionClient
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
})
);
}
)
);

View File

@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -33,12 +32,7 @@ export const DeleteOrganization = ({
setIsDeleting(true);
try {
const result = await deleteOrganizationAction({ organizationId: organization.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsDeleting(false);
return;
}
await deleteOrganizationAction({ organizationId: organization.id });
toast.success(t("environments.settings.general.organization_deleted_successfully"));
if (typeof localStorage !== "undefined") {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
@@ -107,7 +101,7 @@ const DeleteOrganizationModal = ({
}: DeleteOrganizationModalProps) => {
const [inputValue, setInputValue] = useState("");
const { t } = useTranslation();
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleInputChange = (e) => {
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 instanceof Error ? err.message : "Unknown error occurred"}`);
toast.error(`Error: ${err.message}`);
}
};

View File

@@ -9,7 +9,6 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
@@ -26,7 +25,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.id);
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -82,10 +81,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard>
)}
<div className="space-y-2">
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
</div>
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
</PageContentWrapper>
);
};

View File

@@ -4,7 +4,7 @@ import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
const Layout = async (props) => {
const params = await props.params;
const { children } = props;

View File

@@ -1,3 +1,3 @@
export const SettingsTitle = ({ title }: { title: string }) => {
export const SettingsTitle = ({ title }) => {
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: { params: Promise<{ environmentId: string }> }) => {
const Page = async (props) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/settings/profile`);
};

View File

@@ -4,7 +4,6 @@ import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
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";
@@ -23,7 +22,7 @@ const ZGetResponsesAction = z.object({
});
export const getResponsesAction = authenticatedActionClient
.inputSchema(ZGetResponsesAction)
.schema(ZGetResponsesAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -57,7 +56,7 @@ const ZGetSurveySummaryAction = z.object({
});
export const getSurveySummaryAction = authenticatedActionClient
.inputSchema(ZGetSurveySummaryAction)
.schema(ZGetSurveySummaryAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -85,7 +84,7 @@ const ZGetResponseCountAction = z.object({
});
export const getResponseCountAction = authenticatedActionClient
.inputSchema(ZGetResponseCountAction)
.schema(ZGetResponseCountAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -107,31 +106,3 @@ export const getResponseCountAction = authenticatedActionClient
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
});
const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId,
limit: z.int().min(1).max(100),
offset: z.int().nonnegative(),
});
export const getDisplaysWithContactAction = authenticatedActionClient
.inputSchema(ZGetDisplaysWithContactAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
return getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset);
});

View File

@@ -3,7 +3,6 @@ import { getServerSession } from "next-auth";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
type Props = {
@@ -15,11 +14,10 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
const session = await getServerSession(authOptions);
const survey = await getSurvey(params.surveyId);
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const t = await getTranslate();
if (session) {
return {
title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`,
title: `${responseCount} Responses | ${survey?.name} Results`,
};
}
return {
@@ -27,7 +25,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
};
};
const SurveyLayout = async ({ children }: { children: React.ReactNode }) => {
const SurveyLayout = async ({ children }) => {
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
};

View File

@@ -205,11 +205,11 @@ export const ResponseTable = ({
};
// Handle downloading selected responses
const downloadSelectedRows = async (responseIds: string[], format: "xlsx" | "csv") => {
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
try {
const downloadResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format,
format: format,
filterCriteria: { responseIds },
});

View File

@@ -5,7 +5,7 @@ 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, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -41,7 +41,7 @@ const getElementColumnsData = (
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
// Helper function to create consistent column headers
const createElementHeader = (elementType: TSurveyElementTypeEnum, headline: string, suffix?: string) => {
const createElementHeader = (elementType: string, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline;
const ElementHeader = () => (
<div className="flex items-center justify-between">
@@ -232,7 +232,7 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
METADATA_FIELDS.forEach((label) => {
const IconComponent = COLUMNS_ICON_MAP[label as keyof typeof COLUMNS_ICON_MAP];
const IconComponent = COLUMNS_ICON_MAP[label];
metadataColumns.push({
accessorKey: "METADATA_" + label,
@@ -316,14 +316,6 @@ export const generateResponseTableColumns = (
},
};
const responseIdColumn: ColumnDef<TResponseTableData> = {
accessorKey: "responseId",
header: () => <div className="gap-x-1.5">{t("common.response_id")}</div>,
cell: ({ row }) => {
return <IdBadge id={row.original.responseId} />;
},
};
const quotasColumn: ColumnDef<TResponseTableData> = {
accessorKey: "quota",
header: t("common.quota"),
@@ -422,7 +414,6 @@ export const generateResponseTableColumns = (
const baseColumns = [
personColumn,
singleUseIdColumn,
responseIdColumn,
dateColumn,
...(showQuotasColumn ? [quotasColumn] : []),
statusColumn,

View File

@@ -1,5 +1,4 @@
import "@testing-library/jest-dom/vitest";
import { TFunction } from "i18next";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -39,7 +38,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,24 +80,9 @@ 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: (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;
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
if (userAgentFields.includes(label)) {
return meta.userAgent?.[label];
}
return meta[label];
};

View File

@@ -17,7 +17,7 @@ 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: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
@@ -27,7 +27,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(organization.id),
getIsContactsEnabled(),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
@@ -53,7 +53,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
// Fetch initial responses on the server to prevent duplicate client-side fetch

View File

@@ -7,6 +7,7 @@ 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 { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -21,7 +22,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
});
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
.inputSchema(ZSendEmbedSurveyPreviewEmailAction)
.schema(ZSendEmbedSurveyPreviewEmailAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
@@ -57,7 +58,6 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
ctx.user.email,
emailHtml,
survey.environmentId,
ctx.user.locale,
organizationLogoUrl || ""
);
});
@@ -68,43 +68,53 @@ const ZResetSurveyAction = z.object({
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
export const resetSurveyAction = authenticatedActionClient.schema(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,
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
return {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
})
return {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
}
)
);
const ZGetEmailHtmlAction = z.object({
@@ -112,7 +122,7 @@ const ZGetEmailHtmlAction = z.object({
});
export const getEmailHtmlAction = authenticatedActionClient
.inputSchema(ZGetEmailHtmlAction)
.schema(ZGetEmailHtmlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -141,10 +151,9 @@ const ZGeneratePersonalLinksAction = z.object({
});
export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction)
.schema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
}
@@ -221,7 +230,7 @@ const ZUpdateSingleUseLinksAction = z.object({
});
export const updateSingleUseLinksAction = authenticatedActionClient
.inputSchema(ZUpdateSingleUseLinksAction)
.schema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,

View File

@@ -30,7 +30,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.booked.count })}
{elementSummary.booked.count}{" "}
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
@@ -46,7 +47,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.skipped.count })}
{elementSummary.skipped.count}{" "}
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />

View File

@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: summaryItem.count })}
{summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<div className="group-hover:opacity-80">

View File

@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
{showResponses && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_responses", { count: elementSummary.responseCount })}
{`${elementSummary.responseCount} ${t("common.responses")}`}
</div>
)}
{additionalInfo}

View File

@@ -8,7 +8,7 @@ import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surv
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getOriginalFileNameFromUrl } from "@/modules/storage/url-helpers";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";

View File

@@ -41,7 +41,8 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_responses", { count: elementSummary.responseCount })}
{elementSummary.responseCount}{" "}
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
</div>
</div>
</div>

View File

@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
if (label) {
return label;
} else if (percentage !== undefined && totalResponsesForRow !== undefined) {
return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) });
return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`;
}
return "";
};
@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
)}>
<button
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark"
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
onClick={() =>
setFilter(
elementSummary.element.id,

View File

@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
elementSummary.type === "multipleChoiceMulti" ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })}
{`${elementSummary.selectionCount} ${t("common.selections")}`}
</div>
) : undefined
}
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{t("common.count_selections", { count: result.count })}
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%

View File

@@ -60,9 +60,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
},
};
const filter = (filters as Record<string, { comparison: string; values: string | string[] | undefined }>)[
group
];
const filter = filters[group];
if (filter) {
setFilter(
@@ -106,7 +104,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"] as const).map((group) => (
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
@@ -125,7 +123,8 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary[group]?.count })}
{elementSummary[group]?.count}{" "}
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar
@@ -159,7 +158,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
}>
<div className="flex h-32 w-full flex-col items-center justify-end">
<div
className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110"
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
style={{
height: `${Math.max(choice.percentage, 2)}%`,
opacity,

View File

@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
elementSummary.element.allowMulti ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })}
{`${elementSummary.selectionCount} ${t("common.selections")}`}
</div>
) : undefined
}
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{t("common.count_selections", { count: result.count })}
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%

View File

@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
)
}>
<div
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: result.count })}
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
@@ -215,7 +215,8 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
<div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.dismissed.count })}
{elementSummary.dismissed.count}{" "}
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
</div>

View File

@@ -1,125 +0,0 @@
"use client";
import { AlertCircleIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Button } from "@/modules/ui/components/button";
interface SummaryImpressionsProps {
displays: TDisplayWithContact[];
isLoading: boolean;
hasMore: boolean;
displaysError: string | null;
environmentId: string;
locale: TUserLocale;
onLoadMore: () => void;
onRetry: () => void;
}
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
if (!display.contact) return "";
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
};
export const SummaryImpressions = ({
displays,
isLoading,
hasMore,
displaysError,
environmentId,
locale,
onLoadMore,
onRetry,
}: SummaryImpressionsProps) => {
const { t } = useTranslation();
const renderContent = () => {
if (displaysError) {
return (
<div className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex items-center gap-2 text-red-600">
<AlertCircleIcon className="h-5 w-5" />
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
</div>
<p className="text-sm text-slate-500">{displaysError}</p>
<Button onClick={onRetry} variant="secondary" size="sm">
{t("common.try_again")}
</Button>
</div>
</div>
);
}
if (displays.length === 0) {
return (
<div className="p-8 text-center text-sm text-slate-500">
{t("environments.surveys.summary.no_identified_impressions")}
</div>
);
}
return (
<>
<div className="grid min-h-10 grid-cols-4 items-center border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-2 px-4 md:px-6">{t("common.user")}</div>
<div className="col-span-2 px-4 md:px-6">{t("environments.contacts.survey_viewed_at")}</div>
</div>
<div className="max-h-[62vh] overflow-y-auto">
{displays.map((display) => (
<div
key={display.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-xs text-slate-800 last:border-transparent md:text-sm">
<div className="col-span-2 pl-4 md:pl-6">
{display.contact ? (
<Link
className="ph-no-capture break-all text-slate-600 hover:underline"
href={`/environments/${environmentId}/contacts/${display.contact.id}`}>
{getDisplayContactIdentifier(display)}
</Link>
) : (
<span className="break-all text-slate-600">{t("common.anonymous")}</span>
)}
</div>
<div className="col-span-2 px-4 text-slate-500 md:px-6">
{timeSince(display.createdAt.toString(), locale)}
</div>
</div>
))}
</div>
{hasMore && (
<div className="flex justify-center border-t border-slate-100 py-4">
<Button onClick={onLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</>
);
};
if (isLoading) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
<div className="flex items-center justify-center">
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
);
}
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<InfoIcon className="h-4 w-4 shrink-0" />
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
</div>
{renderContent()}
</div>
);
};

View File

@@ -10,12 +10,12 @@ interface SummaryMetadataProps {
surveySummary: TSurveySummary["meta"];
quotasCount: number;
isLoading: boolean;
tab: "dropOffs" | "quotas" | "impressions" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>;
tab: "dropOffs" | "quotas" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
isQuotasAllowed: boolean;
}
const formatTime = (ttc: number) => {
const formatTime = (ttc) => {
const seconds = ttc / 1000;
let formattedValue;
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
const { t } = useTranslation();
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
const handleTabChange = (val: "dropOffs" | "quotas") => {
const change = tab === val ? undefined : val;
setTab(change);
};
@@ -65,16 +65,12 @@ export const SummaryMetadata = ({
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
)}>
<InteractiveCard
key="impressions"
tab="impressions"
<StatCard
label={t("environments.surveys.summary.impressions")}
percentage={null}
value={displayCount === 0 ? <span>-</span> : displayCount}
tooltipText={t("environments.surveys.summary.impressions_tooltip")}
isLoading={isLoading}
onClick={() => handleTabChange("impressions")}
isActive={tab === "impressions"}
/>
<StatCard
label={t("environments.surveys.summary.starts")}

View File

@@ -1,31 +1,21 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
getDisplaysWithContactAction,
getSurveySummaryAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { SummaryImpressions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryImpressions";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
import { SummaryList } from "./SummaryList";
import { SummaryMetadata } from "./SummaryMetadata";
const DISPLAYS_PER_PAGE = 15;
const defaultSurveySummary: TSurveySummary = {
meta: {
completedPercentage: 0,
@@ -61,76 +51,17 @@ export const SummaryPage = ({
initialSurveySummary,
isQuotasAllowed,
}: SummaryPageProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary
);
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
const [hasMoreDisplays, setHasMoreDisplays] = useState(true);
const [displaysError, setDisplaysError] = useState<string | null>(null);
const displaysFetchedRef = useRef(false);
const fetchDisplays = useCallback(
async (offset: number) => {
const response = await getDisplaysWithContactAction({
surveyId,
limit: DISPLAYS_PER_PAGE,
offset,
});
if (!response?.data) {
const errorMessage = getFormattedErrorMessage(response);
throw new Error(errorMessage);
}
return response?.data ?? [];
},
[surveyId]
);
const loadInitialDisplays = useCallback(async () => {
setIsDisplaysLoading(true);
setDisplaysError(null);
try {
const data = await fetchDisplays(0);
setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
setDisplays([]);
setHasMoreDisplays(false);
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays, t]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
const data = await fetchDisplays(displays.length);
setDisplays((prev) => [...prev, ...data]);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
}
}, [fetchDisplays, displays.length, t]);
useEffect(() => {
if (tab === "impressions" && !displaysFetchedRef.current) {
displaysFetchedRef.current = true;
loadInitialDisplays();
}
}, [tab, loadInitialDisplays]);
// Only fetch data when filters change or when there's no initial data
useEffect(() => {
// If we have initial data and no filters are applied, don't fetch
@@ -190,18 +121,6 @@ export const SummaryPage = ({
setTab={setTab}
isQuotasAllowed={isQuotasAllowed}
/>
{tab === "impressions" && (
<SummaryImpressions
displays={displays}
isLoading={isDisplaysLoading}
hasMore={hasMoreDisplays}
displaysError={displaysError}
environmentId={environment.id}
locale={locale}
onLoadMore={handleLoadMoreDisplays}
onRetry={loadInitialDisplays}
/>
)}
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
<div className="flex gap-1.5">

View File

@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
interface InteractiveCardProps {
tab: "dropOffs" | "quotas" | "impressions";
tab: "dropOffs" | "quotas";
label: string;
percentage: number | null;
percentage: number;
value: React.ReactNode;
tooltipText: string;
isLoading: boolean;

View File

@@ -75,7 +75,17 @@ export const ShareSurveyModal = ({
const [showView, setShowView] = useState<ModalView>(modalView);
const { email } = user;
const { t } = useTranslation();
const linkTabs = useMemo(() => {
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 tabs = [
{
id: ShareViaType.ANON_LINKS,

View File

@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
},
{
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
},
]}
/>

View File

@@ -47,7 +47,6 @@ 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"]) => {

View File

@@ -105,7 +105,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
<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">
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
{projectCustomScripts}
</pre>
</div>
@@ -135,7 +135,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
rows={8}
placeholder={t("environments.surveys.share.custom_html.placeholder")}
className={cn(
"flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
)}
{...field}
disabled={isReadOnly}

View File

@@ -39,7 +39,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
}
}
} catch (error) {
logger.error(error as Error, "Failed to generate QR code");
logger.error("Failed to generate QR code:", error);
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(error as Error, "Failed to download QR code");
logger.error("Failed to download QR code:", error);
toast.error(t("environments.surveys.summary.qr_code_download_failed"));
} finally {
setIsDownloading(false);

View File

@@ -4,10 +4,6 @@ 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";
@@ -17,9 +13,9 @@ interface SuccessViewProps {
publicDomain: string;
setSurveyUrl: (url: string) => void;
user: TUser;
tabs: { id: ShareViaType | ShareSettingsType; label: string; icon: React.ElementType }[];
handleViewChange: (view: "start" | "share") => void;
handleEmbedViewWithTab: (tabId: ShareViaType | ShareSettingsType) => void;
tabs: { id: string; label: string; icon: React.ElementType }[];
handleViewChange: (view: string) => void;
handleEmbedViewWithTab: (tabId: string) => void;
isReadOnly: boolean;
}
@@ -70,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
</button>
<Link
href={`/environments/${environmentId}/settings/notifications`}

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

@@ -662,23 +662,17 @@ 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: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
const item1 = (summary[0] as any).choices.find((c) => 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: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
const item2 = (summary[0] as any).choices.find((c) => 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: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
expect(item3.count).toBe(2);
expect(item3.avgRanking).toBe(3);
});
@@ -753,23 +747,17 @@ 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: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
const item1 = (summary[0] as any).choices.find((c) => 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: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
const item2 = (summary[0] as any).choices.find((c) => 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: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
expect(item3.count).toBe(1);
expect(item3.avgRanking).toBe(3);
});
@@ -842,12 +830,10 @@ 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: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
}
);
(summary[0] as any).choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
});
});
test("getQuestionSummary handles ranking question with non-array answers", async () => {
@@ -908,12 +894,10 @@ 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: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
}
);
(summary[0] as any).choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
});
});
test("getQuestionSummary handles ranking question with values not in choices", async () => {
@@ -974,23 +958,17 @@ 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: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
const item1 = (summary[0] as any).choices.find((c) => 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: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
const item2 = (summary[0] as any).choices.find((c) => 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: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
expect(item3.count).toBe(1);
expect(item3.avgRanking).toBe(3);
});
@@ -1008,11 +986,7 @@ 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: Record<string, unknown>) => ({
...r,
contactId: null,
personAttributes: {},
})) as any
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
);
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10);
@@ -1046,8 +1020,8 @@ describe("getSurveySummary", () => {
test("handles filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = { finished: true };
const finishedResponses = mockResponses
.filter((r: Record<string, unknown>) => r.finished)
.map((r: Record<string, unknown>) => ({ ...r, contactId: null, personAttributes: {} }));
.filter((r) => r.finished)
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any);
await getSurveySummary(mockSurveyId, filterCriteria);
@@ -1069,11 +1043,7 @@ describe("getResponsesForSummary", () => {
vi.resetAllMocks();
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r: Record<string, unknown>) => ({
...r,
contactId: null,
personAttributes: {},
})) as any
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
);
// React cache is already mocked globally - no need to mock it again
});
@@ -1872,63 +1842,23 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2);
// Verify Speed row
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
expect(speedRow.totalResponsesForRow).toBe(2);
expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns
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);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
// Verify Quality row
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
expect(qualityRow.totalResponsesForRow).toBe(2);
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);
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(50);
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
// Verify Price row
const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
expect(priceRow.totalResponsesForRow).toBe(2);
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);
expect(priceRow.columnPercentages.find((col) => col.column === "Poor").percentage).toBe(50);
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
});
test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => {
@@ -2019,48 +1949,19 @@ 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: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
expect(speedRow.totalResponsesForRow).toBe(1);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
// Verify Quality row
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
expect(qualityRow.totalResponsesForRow).toBe(1);
expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Excellent"
).percentage
).toBe(100);
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(100);
// Verify Price row
const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
expect(priceRow.totalResponsesForRow).toBe(1);
expect(
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(100);
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
});
test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => {
@@ -2154,18 +2055,12 @@ 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: {
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);
});
}
);
summary[0].data.forEach((row) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col) => {
expect(col.percentage).toBe(0);
});
});
});
test("getQuestionSummary handles partial and incomplete matrix responses", async () => {
@@ -2252,59 +2147,22 @@ 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: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
expect(speedRow.totalResponsesForRow).toBe(2);
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);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
// Verify Quality row - only one response provided data
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
expect(qualityRow.totalResponsesForRow).toBe(1);
expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Good"
).percentage
).toBe(100);
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
// Verify Price row - both responses provided data
const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
expect(priceRow.totalResponsesForRow).toBe(2);
// ExtraRow should not appear in the summary
expect(
summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "ExtraRow"
)
).toBeUndefined();
expect(summary[0].data.find((row) => row.rowLabel === "ExtraRow")).toBeUndefined();
});
test("getQuestionSummary handles zero responses for Matrix question correctly", async () => {
@@ -2363,18 +2221,12 @@ 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: {
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);
}
);
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);
});
});
test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => {
@@ -2444,46 +2296,21 @@ 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: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
expect(speedRow.totalResponsesForRow).toBe(1);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
// Quality row should have no valid responses
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
expect(qualityRow.totalResponsesForRow).toBe(0);
qualityRow.columnPercentages.forEach((col: { column: string; percentage: number }) => {
qualityRow.columnPercentages.forEach((col) => {
expect(col.percentage).toBe(0);
});
// Price row should have a valid response
const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
expect(priceRow.totalResponsesForRow).toBe(1);
expect(
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(100);
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
});
test("getQuestionSummary handles Matrix question with invalid row labels", async () => {
@@ -2554,48 +2381,17 @@ 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: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
expect(speedRow.totalResponsesForRow).toBe(1);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
// Quality row should have no responses
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
expect(qualityRow.totalResponsesForRow).toBe(0);
// Invalid rows should not appear in the summary
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();
expect(summary[0].data.find((row) => row.rowLabel === "InvalidRow")).toBeUndefined();
expect(summary[0].data.find((row) => row.rowLabel === "AnotherInvalidRow")).toBeUndefined();
});
test("getQuestionSummary handles Matrix question with mixed language responses", async () => {
@@ -2697,27 +2493,12 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2);
// Speed row should have both responses
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
expect(speedRow.totalResponsesForRow).toBe(2);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
// Quality row should have no responses
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
expect(qualityRow.totalResponsesForRow).toBe(0);
});
@@ -2776,18 +2557,12 @@ 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: {
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);
});
}
);
summary[0].data.forEach((row) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col) => {
expect(col.percentage).toBe(0);
});
});
});
});
@@ -3219,33 +2994,23 @@ 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: { rating: number; count: number; percentage: number }) => c.rating === 5
);
const rating5 = summary[0].choices.find((c) => c.rating === 5);
expect(rating5.count).toBe(2);
expect(rating5.percentage).toBe(50); // 2/4 * 100
const rating4 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 4
);
const rating4 = summary[0].choices.find((c) => c.rating === 4);
expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25); // 1/4 * 100
const rating3 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 3
);
const rating3 = summary[0].choices.find((c) => c.rating === 3);
expect(rating3.count).toBe(1);
expect(rating3.percentage).toBe(25); // 1/4 * 100
const rating2 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 2
);
const rating2 = summary[0].choices.find((c) => c.rating === 2);
expect(rating2.count).toBe(0);
expect(rating2.percentage).toBe(0);
const rating1 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 1
);
const rating1 = summary[0].choices.find((c) => c.rating === 1);
expect(rating1.count).toBe(0);
expect(rating1.percentage).toBe(0);
@@ -3389,12 +3154,10 @@ describe("Rating question type tests", () => {
expect(summary[0].average).toBe(0);
// Verify all ratings have 0 count and percentage
summary[0].choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
}
);
summary[0].choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
});
// Verify dismissed is 0
expect(summary[0].dismissed.count).toBe(0);
@@ -3469,21 +3232,15 @@ 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: { id: string; count: number; percentage: number }) => c.id === "img1"
);
const img1 = summary[0].choices.find((c) => c.id === "img1");
expect(img1.count).toBe(1);
expect(img1.percentage).toBe(50);
const img2 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
);
const img2 = summary[0].choices.find((c) => c.id === "img2");
expect(img2.count).toBe(1);
expect(img2.percentage).toBe(50);
const img3 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img3"
);
const img3 = summary[0].choices.find((c) => c.id === "img3");
expect(img3.count).toBe(1);
expect(img3.percentage).toBe(50);
});
@@ -3554,12 +3311,10 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(0);
// All choices should have zero count
summary[0].choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
}
);
summary[0].choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
});
});
test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => {
@@ -3618,23 +3373,17 @@ 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: { id: string; count: number; percentage: number }) => c.id === "img1"
);
const img1 = summary[0].choices.find((c) => 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: { id: string; count: number; percentage: number }) => c.id === "img2"
);
const img2 = summary[0].choices.find((c) => 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: { id: string; count: number; percentage: number }) => c.id === "invalid-id")
).toBeUndefined();
expect(summary[0].choices.find((c) => c.id === "invalid-id")).toBeUndefined();
});
});

View File

@@ -14,7 +14,11 @@ import {
TResponseVariables,
ZResponseFilterCriteria,
} from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TSurveyElement,
TSurveyElementChoice,
TSurveyElementTypeEnum,
} from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyElementSummaryAddress,
@@ -289,10 +293,7 @@ const checkForI18n = (
) => {
const element = elements.find((element) => element.id === id);
if (
element?.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
element?.type === TSurveyElementTypeEnum.Ranking
) {
if (element?.type === "multipleChoiceMulti" || element?.type === "ranking") {
// Initialize an array to hold the choice values
let choiceValues = [] as string[];
@@ -317,9 +318,13 @@ const checkForI18n = (
}
// Return the localized value of the choice fo multiSelect single element
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];
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];
}
return responseData[id];
@@ -827,19 +832,13 @@ export const getElementSummary = async (
let totalResponseCount = 0;
// Initialize count object
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>>
);
const countMap: Record<string, string> = rows.reduce((acc, row) => {
acc[row] = columns.reduce((colAcc, col) => {
colAcc[col] = 0;
return colAcc;
}, {});
return acc;
}, {});
responses.forEach((response) => {
const selectedResponses = response.data[element.id] as Record<string, string>;
@@ -1096,7 +1095,7 @@ export const getResponsesForSummary = reactCache(
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.cuid2().optional()]
[cursor, z.string().cuid2().optional()]
);
const queryLimit = limit ?? RESPONSES_PER_PAGE;

View File

@@ -40,11 +40,10 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!user) {
throw new Error(t("common.user_not_found"));
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) {
throw new Error(t("common.organization_not_found"));
}
@@ -52,7 +51,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);

View File

@@ -4,16 +4,18 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
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 { 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 { 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";
@@ -26,7 +28,7 @@ const ZGetResponsesDownloadUrlAction = z.object({
});
export const getResponsesDownloadUrlAction = authenticatedActionClient
.inputSchema(ZGetResponsesDownloadUrlAction)
.schema(ZGetResponsesDownloadUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -56,7 +58,7 @@ const ZGetSurveyFilterDataAction = z.object({
});
export const getSurveyFilterDataAction = authenticatedActionClient
.inputSchema(ZGetSurveyFilterDataAction)
.schema(ZGetSurveyFilterDataAction)
.action(async ({ ctx, parsedInput }) => {
const survey = await getSurvey(parsedInput.surveyId);
@@ -87,7 +89,7 @@ export const getSurveyFilterDataAction = authenticatedActionClient
throw new ResourceNotFoundError("Organization", organizationId);
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
getTagsByEnvironmentId(survey.environmentId),
@@ -113,52 +115,60 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(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 }) => {
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",
},
],
});
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging(
"updated",
"survey",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
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;
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(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: Record<string, unknown>, parentKey = "") => {
const extractMetadataKeys = useCallback((obj, parentKey = "") => {
let keys: string[] = [];
for (let key in obj) {
if (typeof obj[key] === "object" && obj[key] !== null) {
keys = keys.concat(extractMetadataKeys(obj[key] as Record<string, unknown>, parentKey + key + " - "));
keys = keys.concat(extractMetadataKeys(obj[key], parentKey + key + " - "));
} else {
keys.push(parentKey + key);
}

View File

@@ -113,9 +113,7 @@ const elementIcons = {
};
const getIcon = (type: string) => {
const IconComponent = (elementIcons as Record<string, (typeof elementIcons)[keyof typeof elementIcons]>)[
type
];
const IconComponent = elementIcons[type];
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
};
@@ -194,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
value={inputValue}
onValueChange={setInputValue}
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
/>
)}
<Button

View File

@@ -198,7 +198,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
};
setFilterValue({ ...filterValue });
};
const handleRemoveMultiSelect = (value: string[], index: number) => {
const handleRemoveMultiSelect = (value: string[], index) => {
filterValue.filter[index] = {
...filterValue.filter[index],
filterType: {
@@ -241,7 +241,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<PopoverTriggerButton isOpen={isOpen}>
{t("common.filter")} <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
</PopoverTriggerButton>
</PopoverTrigger>
<PopoverContent
@@ -329,7 +329,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
</div>
{i !== filterValue.filter.length - 1 && (
<div className="my-4 flex items-center">
<p className="mr-4 font-semibold text-slate-800">{t("common.and")}</p>
<p className="mr-4 font-semibold text-slate-800">and</p>
<hr className="w-full text-slate-600" />
</div>
)}

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