Compare commits

..

1 Commits

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

View File

@@ -184,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=

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

@@ -32,20 +32,21 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js 22.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
- name: Setup Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 22.x
node-version: 18
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup pnpm
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
with:
version: 9.15.9
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile
- name: Validate translation keys
run: |

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/

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

@@ -23,7 +23,7 @@
"@tailwindcss/vite": "4.1.18",
"@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
"esbuild": "0.25.12",
"esbuild": "0.27.2",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.1.11",
"prop-types": "15.8.1",

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,4 +1,4 @@
FROM node:24-alpine3.23 AS base
FROM node:22-alpine3.22 AS base
#
## step 1: Prune monorepo
@@ -20,7 +20,7 @@ FROM base AS installer
# Enable corepack and prepare pnpm
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
@@ -69,14 +69,20 @@ RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=sentry_auth_token \
/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
# Update npm to latest, then create user
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
RUN npm install --ignore-scripts -g npm@latest \
RUN 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
@@ -107,44 +113,25 @@ RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./package
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
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
RUN chmod -R 755 ./node_modules/uuid
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
@@ -154,8 +141,10 @@ 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/

View File

@@ -25,7 +25,7 @@ const mockProject: TProject = {
},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
languages: [],
logo: null,

View File

@@ -0,0 +1,20 @@
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
import { Header } from "@/modules/ui/components/header";
interface SelectPlanOnboardingProps {
organizationId: string;
}
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="Ship professional, unbranded surveys today!"
subtitle="No credit card required, no strings attached."
/>
<SelectPlanCard nextUrl={nextUrl} />
</div>
);
};

View File

@@ -0,0 +1,29 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
interface PlanPageProps {
params: Promise<{
organizationId: string;
}>;
}
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`);
}
const { session } = await getOrganizationAuth(params.organizationId);
if (!session?.user) {
return redirect(`/auth/login`);
}
return <SelectPlanOnboarding organizationId={params.organizationId} />;
};
export default Page;

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) => ({
@@ -235,7 +226,7 @@ export const ProjectSettings = ({
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>
@@ -244,7 +235,7 @@ export const ProjectSettings = ({
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={previewStyling}
styling={{ brandColor: { light: brandColor } }}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}

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";
@@ -138,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

@@ -36,7 +36,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
// 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.billing.limits);
const isOwnerOrManager = isOwner || isManager;
@@ -63,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

@@ -109,10 +109,7 @@ export const MainNavigation = ({
href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
},
{
name: t("common.configuration"),

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

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

View File

@@ -1,142 +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 { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { SettingsCard } from "../../../components/SettingsCard";
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
interface EnterpriseLicenseStatusProps {
status: LicenseStatus;
gracePeriodEnd?: Date;
environmentId: string;
}
const getBadgeConfig = (
status: LicenseStatus,
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 "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 === "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>
)}
<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,10 +2,9 @@ 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";
@@ -26,8 +25,7 @@ const Page = async (props) => {
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) => {
activeId="enterprise"
/>
</PageHeader>
{hasLicense ? (
<EnterpriseLicenseStatus
status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
{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) => {
{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,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);

View File

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

@@ -58,7 +58,6 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
ctx.user.email,
emailHtml,
survey.environmentId,
ctx.user.locale,
organizationLogoUrl || ""
);
});

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

@@ -21,7 +21,6 @@ import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[envir
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
@@ -269,14 +268,7 @@ export const AddIntegrationModal = ({
airtableIntegrationData.config?.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: airtableIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: airtableIntegrationData });
if (isEditMode) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -312,11 +304,7 @@ export const AddIntegrationModal = ({
const integrationData = structuredClone(airtableIntegrationData);
integrationData.config.data.splice(index, 1);
const result = await createOrUpdateIntegrationAction({ environmentId, integrationData });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData });
handleClose();
router.refresh();

View File

@@ -1,49 +1,12 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
} from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/googleSheet/service";
import { getIntegrationByType } from "@/lib/integration/service";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
const ZValidateGoogleSheetsConnectionAction = z.object({
environmentId: ZId,
});
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.schema(ZValidateGoogleSheetsConnectionAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
if (!integration) {
return { data: false };
}
await validateGoogleSheetsConnection(integration as TIntegrationGoogleSheets);
return { data: true };
});
const ZGetSpreadsheetNameByIdAction = z.object({
googleSheetIntegration: ZIntegrationGoogleSheets,
environmentId: z.string(),

View File

@@ -20,10 +20,6 @@ import {
isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -122,17 +118,6 @@ export const AddIntegrationModal = ({
resetForm();
}, [selectedIntegration, surveys]);
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
const errorMessage = getFormattedErrorMessage(response);
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
toast.error(t("environments.integrations.google_sheets.token_expired_error"));
} else if (errorMessage === GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION) {
toast.error(t("environments.integrations.google_sheets.spreadsheet_permission_error"));
} else {
toast.error(errorMessage);
}
};
const linkSheet = async () => {
try {
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
@@ -144,7 +129,6 @@ export const AddIntegrationModal = ({
if (selectedElements.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
}
setIsLinkingSheet(true);
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
googleSheetIntegration,
@@ -153,11 +137,13 @@ export const AddIntegrationModal = ({
});
if (!spreadsheetNameResponse?.data) {
showErrorMessageToast(spreadsheetNameResponse);
return;
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
throw new Error(errorMessage);
}
const spreadsheetName = spreadsheetNameResponse.data;
setIsLinkingSheet(true);
integrationData.spreadsheetId = spreadsheetId;
integrationData.spreadsheetName = spreadsheetName;
integrationData.surveyId = selectedSurvey.id;
@@ -179,14 +165,7 @@ export const AddIntegrationModal = ({
// create action
googleSheetIntegrationData.config.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: googleSheetIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -226,14 +205,7 @@ export const AddIntegrationModal = ({
googleSheetIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: googleSheetIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
@@ -294,7 +266,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{surveyElements.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
@@ -8,11 +8,9 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { AddIntegrationModal } from "./AddIntegrationModal";
@@ -37,23 +35,10 @@ export const GoogleSheetWrapper = ({
googleSheetIntegration ? googleSheetIntegration.config?.key : false
);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
>(null);
const validateConnection = useCallback(async () => {
if (!isConnected || !googleSheetIntegration) return;
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id });
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
setShowReconnectButton(true);
}
}, [environment.id, isConnected, googleSheetIntegration]);
useEffect(() => {
validateConnection();
}, [validateConnection]);
const handleGoogleAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => {
if (url) {
@@ -79,8 +64,6 @@ export const GoogleSheetWrapper = ({
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
showReconnectButton={showReconnectButton}
handleGoogleAuthorization={handleGoogleAuthorization}
locale={locale}
/>
</>

View File

@@ -1,6 +1,6 @@
"use client";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,19 +12,15 @@ import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ManageIntegrationProps {
googleSheetIntegration: TIntegrationGoogleSheets;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
showReconnectButton: boolean;
handleGoogleAuthorization: () => void;
locale: TUserLocale;
}
@@ -33,8 +29,6 @@ export const ManageIntegration = ({
setOpenAddIntegrationModal,
setIsConnected,
setSelectedIntegration,
showReconnectButton,
handleGoogleAuthorization,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslation();
@@ -74,17 +68,7 @@ export const ManageIntegration = ({
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>
{t("environments.integrations.google_sheets.reconnect_button_description")}
</AlertDescription>
<AlertButton onClick={handleGoogleAuthorization}>
{t("environments.integrations.google_sheets.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="flex w-full justify-end">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
@@ -93,19 +77,6 @@ export const ManageIntegration = ({
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleGoogleAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.google_sheets.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("environments.integrations.google_sheets.reconnect_button_tooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setSelectedIntegration(null);

View File

@@ -22,7 +22,6 @@ import {
createEmptyMapping,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
import NotionLogo from "@/images/notion.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { Button } from "@/modules/ui/components/button";
@@ -218,14 +217,7 @@ export const AddIntegrationModal = ({
notionIntegrationData.config.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: notionIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -244,14 +236,7 @@ export const AddIntegrationModal = ({
notionIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: notionIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {

View File

@@ -17,7 +17,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import SlackLogo from "@/images/slacklogo.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
@@ -145,14 +144,7 @@ export const AddChannelMappingModal = ({
// create action
slackIntegrationData.config.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: slackIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -189,14 +181,7 @@ export const AddChannelMappingModal = ({
slackIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: slackIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {

View File

@@ -21,7 +21,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
let result: string[] = [];
@@ -257,16 +256,10 @@ const processElementResponse = (
const selectedChoiceIds = responseValue as string[];
return element.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
.map((choice) => choice.imageUrl)
.join("\n");
}
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
return responseValue
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
.join("; ");
}
return processResponseData(responseValue);
};
@@ -375,7 +368,7 @@ const buildNotionPayloadProperties = (
responses[resp] = (pictureElement as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
.map((choice) => choice.imageUrl);
}
});

View File

@@ -18,7 +18,6 @@ import { convertDatesInObject } from "@/lib/time";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
@@ -31,10 +30,7 @@ export const POST = async (request: Request) => {
}
const jsonInput = await request.json();
const convertedJsonInput = convertDatesInObject(
jsonInput,
new Set(["contactAttributes", "variables", "data", "meta"])
);
const convertedJsonInput = convertDatesInObject(jsonInput);
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
@@ -96,15 +92,12 @@ export const POST = async (request: Request) => {
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
const webhookPromises = webhooks.map((webhook) => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
data: resolvedResponseData,
survey: {
title: survey.name,
type: survey.type,
@@ -222,14 +215,7 @@ export const POST = async (request: Request) => {
}
const emailPromises = usersWithNotifications.map((user) =>
sendResponseFinishedEmail(
user.email,
user.locale,
environmentId,
survey,
response,
responseCount
).catch((error) => {
sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount).catch((error) => {
logger.error(
{ error, url: request.url, userEmail: user.email },
`Failed to send email to ${user.email}`

View File

@@ -1,6 +1,4 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response";
import {
GOOGLE_SHEETS_CLIENT_ID,
@@ -8,29 +6,18 @@ import {
GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL,
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { createOrUpdateIntegration } from "@/lib/integration/service";
export const GET = async (req: Request) => {
const url = new URL(req.url);
const environmentId = url.searchParams.get("state");
const code = url.searchParams.get("code");
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
if (!environmentId) {
return responses.badRequestResponse("Invalid environmentId");
}
const session = await getServerSession(authOptions);
if (!session) {
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}
@@ -43,39 +30,33 @@ export const GET = async (req: Request) => {
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
if (!code) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
let key;
let userEmail;
if (code) {
const token = await oAuth2Client.getToken(code);
key = token.res?.data;
// Set credentials using the provided token
oAuth2Client.setCredentials({
access_token: key.access_token,
});
// Fetch user's email
const oauth2 = google.oauth2({
auth: oAuth2Client,
version: "v2",
});
const userInfo = await oauth2.userinfo.get();
userEmail = userInfo.data.email;
}
const token = await oAuth2Client.getToken(code);
const key = token.res?.data;
if (!key) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
}
oAuth2Client.setCredentials({ access_token: key.access_token });
const oauth2 = google.oauth2({ auth: oAuth2Client, version: "v2" });
const userInfo = await oauth2.userinfo.get();
const userEmail = userInfo.data.email;
if (!userEmail) {
return responses.internalServerErrorResponse("Failed to get user email");
}
const integrationType = "googleSheets" as const;
const existingIntegration = await getIntegrationByType(environmentId, integrationType);
const existingConfig = existingIntegration?.config as TIntegrationGoogleSheetsConfig;
const googleSheetIntegration = {
type: integrationType,
type: "googleSheets" as "googleSheets",
environment: environmentId,
config: {
key,
data: existingConfig?.data ?? [],
data: [],
email: userEmail,
},
};

View File

@@ -0,0 +1,180 @@
// Deprecated: This api route is deprecated now and will be removed in the future.
// Deprecated: This is currently only being used for the older react native SDKs. Please upgrade to the latest SDKs.
import { NextRequest, userAgent } from "next/server";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsPeopleUserIdInput, ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getContactByUserId } from "@/app/api/v1/client/[environmentId]/app/sync/lib/contact";
import { getSyncSurveys } from "@/app/api/v1/client/[environmentId]/app/sync/lib/survey";
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getActionClasses } from "@/lib/actionClass/service";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironment, updateEnvironment } from "@/lib/environment/service";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
const validateInput = (
environmentId: string,
userId: string
): { isValid: true; data: TJsPeopleUserIdInput } | { isValid: false; error: Response } => {
const inputValidation = ZJsPeopleUserIdInput.safeParse({ environmentId, userId });
if (!inputValidation.success) {
return {
isValid: false,
error: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
return { isValid: true, data: inputValidation.data };
};
const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
if (!IS_FORMBRICKS_CLOUD) return false;
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
logger.error({ environmentId }, "Organization does not exist");
// fail closed if the organization does not exist
return true;
}
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
return isLimitReached;
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string; userId: string }> };
}) => {
const params = await props.params;
try {
const { device } = userAgent(req);
// validate using zod
const validation = validateInput(params.environmentId, params.userId);
if (!validation.isValid) {
return { response: validation.error };
}
const { environmentId, userId } = validation.data;
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
const project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error("Project not found");
}
if (!environment.appSetupCompleted) {
await updateEnvironment(environment.id, { appSetupCompleted: true });
}
// check organization subscriptions and response limits
const isAppSurveyResponseLimitReached = await checkResponseLimit(environmentId);
let contact = await getContactByUserId(environmentId, userId);
if (!contact) {
contact = await prisma.contact.create({
data: {
attributes: {
create: {
attributeKey: {
connect: {
key_environmentId: {
key: "userId",
environmentId,
},
},
},
value: userId,
},
},
environment: { connect: { id: environmentId } },
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
}
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
acc[attribute.attributeKey.key] = attribute.value;
return acc;
}, {}) as Record<string, string>;
const [surveys, actionClasses] = await Promise.all([
getSyncSurveys(
environmentId,
contact.id,
contactAttributes,
device.type === "mobile" ? "phone" : "desktop"
),
getActionClasses(environmentId),
]);
const updatedProject: any = {
...project,
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(project.styling.highlightBorderColor?.light && {
highlightBorderColor: project.styling.highlightBorderColor.light,
}),
};
const language = contactAttributes["language"];
// Scenario 1: Multi language and updated trigger action classes supported.
// Use the surveys as they are.
let transformedSurveys: TSurvey[] = surveys;
// creating state object
let state = {
surveys: !isAppSurveyResponseLimitReached
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
: [],
actionClasses,
language,
project: updatedProject,
};
return {
response: responses.successResponse({ ...state }, true),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]");
return {
response: responses.internalServerErrorResponse(
"Unable to handle the request: " + error.message,
true
),
};
}
},
});

View File

@@ -0,0 +1,83 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContact } from "@/modules/ee/contacts/types/contact";
import { getContactByUserId } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
},
},
}));
const environmentId = "test-environment-id";
const userId = "test-user-id";
const contactId = "test-contact-id";
const contactMock: Partial<TContact> & {
attributes: { value: string; attributeKey: { key: string } }[];
} = {
id: contactId,
attributes: [
{ attributeKey: { key: "userId" }, value: userId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
],
};
describe("getContactByUserId", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("should return contact if found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(contactMock as any);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
expect(contact).toEqual(contactMock);
});
test("should return null if contact not found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
expect(contact).toBeNull();
});
});

View File

@@ -0,0 +1,42 @@
import "server-only";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
export const getContactByUserId = reactCache(
async (
environmentId: string,
userId: string
): Promise<{
attributes: {
value: string;
attributeKey: {
key: string;
};
}[];
id: string;
} | null> => {
const contact = await prisma.contact.findFirst({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
if (!contact) {
return null;
}
return contact;
}
);

View File

@@ -0,0 +1,323 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurveys } from "@/lib/survey/service";
import { anySurveyHasFilters } from "@/lib/survey/utils";
import { diffInDays } from "@/lib/utils/datetime";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
import { getSyncSurveys } from "./survey";
vi.mock("@/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/lib/survey/utils", () => ({
anySurveyHasFilters: vi.fn(),
}));
vi.mock("@/lib/utils/datetime", () => ({
diffInDays: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
evaluateSegment: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
findMany: vi.fn(),
},
response: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const environmentId = "test-env-id";
const contactId = "test-contact-id";
const contactAttributes = { userId: "user1", email: "test@example.com" };
const deviceType = "desktop";
const mockProject = {
id: "proj1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org1",
environments: [],
recontactDays: 10,
inAppSurveyBranding: true,
linkSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
languages: [],
} as unknown as TProject;
const baseSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey 1",
environmentId: environmentId,
type: "app",
status: "inProgress",
questions: [],
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
segment: null,
surveyClosedMessage: null,
singleUse: null,
styling: null,
pin: null,
displayLimit: null,
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
endings: [],
triggers: [],
languages: [],
variables: [],
hiddenFields: { enabled: false },
createdBy: null,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],
recaptcha: { enabled: false, threshold: 0.5 },
};
// Helper function to create mock display objects
const createMockDisplay = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({
id,
createdAt: createdAt || new Date(),
updatedAt: new Date(),
surveyId,
contactId,
responseId: null,
status: null,
});
// Helper function to create mock response objects
const createMockResponse = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({
id,
createdAt: createdAt || new Date(),
updatedAt: new Date(),
finished: false,
surveyId,
contactId,
endingId: null,
data: {},
variables: {},
ttc: {},
meta: {},
contactAttributes: null,
singleUseId: null,
language: null,
displayId: null,
});
describe("getSyncSurveys", () => {
beforeEach(() => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
vi.mocked(evaluateSegment).mockResolvedValue(true);
vi.mocked(diffInDays).mockReturnValue(100); // Assume enough days passed
});
afterEach(() => {
vi.resetAllMocks();
});
test("should throw error if product not found", async () => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
"Project not found"
);
});
test("should return empty array if no surveys found", async () => {
vi.mocked(getSurveys).mockResolvedValue([]);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
});
test("should return empty array if no 'app' type surveys in progress", async () => {
const surveys: TSurvey[] = [
{ ...baseSurvey, id: "s1", type: "link", status: "inProgress" },
{ ...baseSurvey, id: "s2", type: "app", status: "paused" },
];
vi.mocked(getSurveys).mockResolvedValue(surveys);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
});
test("should filter by displayOption 'displayOnce'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayOnce" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Already displayed
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.display.findMany).mockResolvedValue([]); // Not displayed yet
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should filter by displayOption 'displayMultiple'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayMultiple" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]); // Already responded
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.response.findMany).mockResolvedValue([]); // Not responded yet
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should filter by displayOption 'displaySome'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displaySome", displayLimit: 2 }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([
createMockDisplay("d1", "s1", contactId),
createMockDisplay("d2", "s1", contactId),
]); // Display limit reached
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Within limit
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
// Test with response already submitted
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]);
const result3 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result3).toEqual([]);
});
test("should not filter by displayOption 'respondMultiple'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "respondMultiple" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]);
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual(surveys);
});
test("should filter by product recontactDays if survey recontactDays is null", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", recontactDays: null }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
const displayDate = new Date();
vi.mocked(prisma.display.findMany).mockResolvedValue([
createMockDisplay("d1", "s2", contactId, displayDate), // Display for another survey
]);
vi.mocked(diffInDays).mockReturnValue(5); // Not enough days passed (product.recontactDays = 10)
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
expect(diffInDays).toHaveBeenCalledWith(expect.any(Date), displayDate);
vi.mocked(diffInDays).mockReturnValue(15); // Enough days passed
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should return surveys if no segment filters exist", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual(surveys);
expect(evaluateSegment).not.toHaveBeenCalled();
});
test("should evaluate segment filters if they exist", async () => {
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
// Case 1: Segment evaluation matches
vi.mocked(evaluateSegment).mockResolvedValue(true);
const result1 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result1).toEqual(surveys);
expect(evaluateSegment).toHaveBeenCalledWith(
{
attributes: contactAttributes,
deviceType,
environmentId,
contactId,
userId: contactAttributes.userId,
},
segment.filters
);
// Case 2: Segment evaluation does not match
vi.mocked(evaluateSegment).mockResolvedValue(false);
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual([]);
});
test("should handle Prisma errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(getSurveys).mockRejectedValue(prismaError);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
DatabaseError
);
expect(logger.error).toHaveBeenCalledWith(prismaError);
});
test("should handle general errors", async () => {
const generalError = new Error("Something went wrong");
vi.mocked(getSurveys).mockRejectedValue(generalError);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
generalError
);
});
test("should throw ResourceNotFoundError if resolved surveys are null after filtering", async () => {
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
vi.mocked(evaluateSegment).mockResolvedValue(false); // Ensure all surveys are filtered out
// This scenario is tricky to force directly as the code checks `if (!surveys)` before returning.
// However, if `Promise.all` somehow resolved to null/undefined (highly unlikely), it should throw.
// We can simulate this by mocking `Promise.all` if needed, but the current code structure makes this hard to test.
// Let's assume the filter logic works correctly and test the intended path.
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]); // Expect empty array, not an error in this case.
});
});

View File

@@ -0,0 +1,148 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurveys } from "@/lib/survey/service";
import { anySurveyHasFilters } from "@/lib/survey/utils";
import { diffInDays } from "@/lib/utils/datetime";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
export const getSyncSurveys = reactCache(
async (
environmentId: string,
contactId: string,
contactAttributes: Record<string, string | number>,
deviceType: "phone" | "desktop" = "desktop"
): Promise<TSurvey[]> => {
validateInputs([environmentId, ZId]);
try {
const project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error("Project not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "app");
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
}
const displays = await prisma.display.findMany({
where: {
contactId,
},
});
const responses = await prisma.response.findMany({
where: {
contactId,
},
});
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
switch (survey.displayOption) {
case "respondMultiple":
return true;
case "displayOnce":
return displays.filter((display) => display.surveyId === survey.id).length === 0;
case "displayMultiple":
if (!responses) return true;
else {
return responses.filter((response) => response.surveyId === survey.id).length === 0;
}
case "displaySome":
if (survey.displayLimit === null) {
return true;
}
if (responses && responses.filter((response) => response.surveyId === survey.id).length !== 0) {
return false;
}
return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit;
default:
throw Error("Invalid displayOption");
}
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = surveys.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (project.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= project.recontactDays;
} else {
return true;
}
});
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
}
// if no surveys have segment filters, return the surveys
if (!anySurveyHasFilters(surveys)) {
return surveys;
}
// the surveys now have segment filters, so we need to evaluate them
const surveyPromises = surveys.map(async (survey) => {
const { segment } = survey;
// if the survey has no segment, or the segment has no filters, we return the survey
if (!segment || !segment.filters?.length) {
return survey;
}
// Evaluate the segment filters
const result = await evaluateSegment(
{
attributes: contactAttributes ?? {},
deviceType,
environmentId,
contactId,
userId: String(contactAttributes.userId),
},
segment.filters
);
return result ? survey : null;
});
const resolvedSurveys = await Promise.all(surveyPromises);
surveys = resolvedSurveys.filter((survey) => !!survey) as TSurvey[];
if (!surveys) {
throw new ResourceNotFoundError("Survey", environmentId);
}
return surveys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
}
);

View File

@@ -0,0 +1,245 @@
import { describe, expect, test, vi } from "vitest";
import { TAttributes } from "@formbricks/types/attributes";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyEnding,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { parseRecallInfo } from "@/lib/utils/recall";
import { replaceAttributeRecall } from "./utils";
vi.mock("@/lib/utils/recall", () => ({
parseRecallInfo: vi.fn((text, attributes) => {
const recallPattern = /recall:([a-zA-Z0-9_-]+)/;
const match = text.match(recallPattern);
if (match && match[1]) {
const recallKey = match[1];
const attributeValue = attributes[recallKey];
if (attributeValue !== undefined) {
return text.replace(recallPattern, `parsed-${attributeValue}`);
}
}
return text; // Return original text if no match or attribute not found
}),
}));
const baseSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
environmentId: "env1",
type: "app",
status: "inProgress",
questions: [],
endings: [],
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
languages: [
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
],
triggers: [],
recontactDays: null,
displayLimit: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
hiddenFields: { enabled: false },
variables: [],
createdBy: null,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],
recaptcha: { enabled: false, threshold: 0.5 },
displayOption: "displayOnce",
autoClose: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
segment: null,
pin: null,
metadata: {},
};
const attributes: TAttributes = {
name: "John Doe",
email: "john.doe@example.com",
plan: "premium",
};
describe("replaceAttributeRecall", () => {
test("should replace recall info in question headlines and subheaders", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Hello recall:name!" },
subheader: { default: "Your email is recall:email" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
expect(result.questions[0].subheader?.default).toBe("Your email is parsed-john.doe@example.com");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your email is recall:email", attributes);
});
test("should replace recall info in welcome card headline", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome, recall:name!" },
subheader: { default: "<p>Some content</p>" },
buttonLabel: { default: "Start" },
timeToFinish: false,
showResponseCount: false,
},
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.welcomeCard.headline?.default).toBe("Welcome, parsed-John Doe!");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Welcome, recall:name!", attributes);
});
test("should replace recall info in end screen headlines and subheaders", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
endings: [
{
type: "endScreen",
headline: { default: "Thank you, recall:name!" },
subheader: { default: "Your plan: recall:plan" },
buttonLabel: { default: "Finish" },
buttonLink: "https://example.com",
} as unknown as TSurveyEnding,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.endings[0].type).toBe("endScreen");
if (result.endings[0].type === "endScreen") {
expect(result.endings[0].headline?.default).toBe("Thank you, parsed-John Doe!");
expect(result.endings[0].subheader?.default).toBe("Your plan: parsed-premium");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Thank you, recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your plan: recall:plan", attributes);
}
});
test("should handle multiple languages", () => {
const surveyMultiLang: TSurvey = {
...baseSurvey,
languages: [
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
{ language: { id: "lang2", code: "es" } as unknown as TLanguage, default: false, enabled: true },
],
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Hello recall:name!", es: "Hola recall:name!" },
required: true,
buttonLabel: { default: "Next", es: "Siguiente" },
placeholder: { default: "Type here...", es: "Escribe aquí..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyMultiLang, attributes);
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
expect(result.questions[0].headline.es).toBe("Hola parsed-John Doe!");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hola recall:name!", attributes);
});
test("should not replace if recall key is not in attributes", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Your company: recall:company" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.questions[0].headline.default).toBe("Your company: recall:company");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your company: recall:company", attributes);
});
test("should handle surveys with no recall information", async () => {
const surveyNoRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Just a regular question" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
welcomeCard: {
enabled: true,
headline: { default: "Welcome!" },
subheader: { default: "<p>Some content</p>" },
buttonLabel: { default: "Start" },
timeToFinish: false,
showResponseCount: false,
},
endings: [
{
type: "endScreen",
headline: { default: "Thank you!" },
buttonLabel: { default: "Finish" },
} as unknown as TSurveyEnding,
],
};
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
const result = replaceAttributeRecall(surveyNoRecall, attributes);
expect(result).toEqual(surveyNoRecall); // Should be unchanged
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
parseRecallInfoSpy.mockRestore();
});
test("should handle surveys with empty questions, endings, or disabled welcome card", async () => {
const surveyEmpty: TSurvey = {
...baseSurvey,
questions: [],
endings: [],
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
};
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
const result = replaceAttributeRecall(surveyEmpty, attributes);
expect(result).toEqual(surveyEmpty);
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
parseRecallInfoSpy.mockRestore();
});
});

View File

@@ -0,0 +1,55 @@
import { TAttributes } from "@formbricks/types/attributes";
import { TSurvey } from "@formbricks/types/surveys/types";
import { parseRecallInfo } from "@/lib/utils/recall";
export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes): TSurvey => {
const surveyTemp = structuredClone(survey);
const languages = surveyTemp.languages
.map((surveyLanguage) => {
if (surveyLanguage.default) {
return "default";
}
if (surveyLanguage.enabled) {
return surveyLanguage.language.code;
}
return null;
})
.filter((language): language is string => language !== null);
surveyTemp.questions.forEach((question) => {
languages.forEach((language) => {
if (question.headline[language]?.includes("recall:")) {
question.headline[language] = parseRecallInfo(question.headline[language], attributes);
}
if (question.subheader && question.subheader[language]?.includes("recall:")) {
question.subheader[language] = parseRecallInfo(question.subheader[language], attributes);
}
});
});
if (surveyTemp.welcomeCard.enabled && surveyTemp.welcomeCard.headline) {
languages.forEach((language) => {
if (surveyTemp.welcomeCard.headline && surveyTemp.welcomeCard.headline[language]?.includes("recall:")) {
surveyTemp.welcomeCard.headline[language] = parseRecallInfo(
surveyTemp.welcomeCard.headline[language],
attributes
);
}
});
}
surveyTemp.endings.forEach((ending) => {
if (ending.type === "endScreen") {
languages.forEach((language) => {
if (ending.headline && ending.headline[language]?.includes("recall:")) {
ending.headline[language] = parseRecallInfo(ending.headline[language], attributes);
if (ending.subheader && ending.subheader[language]?.includes("recall:")) {
ending.subheader[language] = parseRecallInfo(ending.subheader[language], attributes);
}
}
});
}
});
return surveyTemp;
};

View File

@@ -0,0 +1,6 @@
import {
OPTIONS,
PUT,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
export { OPTIONS, PUT };

View File

@@ -1,314 +0,0 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentStateData } from "./data";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/modules/survey/lib/utils", () => ({
transformPrismaSurvey: vi.fn((survey) => survey),
}));
const environmentId = "cjld2cjxh0000qzrmn831i7rn";
const mockEnvironmentData = {
id: environmentId,
type: "production",
appSetupCompleted: true,
project: {
id: "project-123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
},
actionClasses: [
{
id: "action-1",
type: "code",
name: "Test Action",
key: "test-action",
noCodeConfig: null,
},
],
surveys: [
{
id: "survey-1",
name: "Test Survey",
type: "app",
status: "inProgress",
welcomeCard: { enabled: false },
questions: [],
blocks: null,
variables: [],
showLanguageSwitch: false,
languages: [],
endings: [],
autoClose: null,
styling: null,
recaptcha: { enabled: false },
segment: null,
recontactDays: null,
displayLimit: null,
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
triggers: [],
displayPercentage: null,
delay: 0,
projectOverwrites: null,
},
],
};
describe("getEnvironmentStateData", () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return environment state data when environment exists", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironmentData as never);
const result = await getEnvironmentStateData(environmentId);
expect(result).toEqual({
environment: {
id: environmentId,
type: "production",
appSetupCompleted: true,
project: {
id: "project-123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
},
},
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
surveys: mockEnvironmentData.surveys,
actionClasses: mockEnvironmentData.actionClasses,
});
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: environmentId },
select: expect.objectContaining({
id: true,
type: true,
appSetupCompleted: true,
project: expect.any(Object),
actionClasses: expect.any(Object),
surveys: expect.any(Object),
}),
});
});
test("should throw ResourceNotFoundError when environment is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("environment");
});
test("should throw ResourceNotFoundError when project is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: null,
} as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when organization is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: null,
},
} as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma database errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Connection failed", {
code: "P2024",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalled();
});
test("should rethrow unexpected errors", async () => {
const unexpectedError = new Error("Unexpected error");
vi.mocked(prisma.environment.findUnique).mockRejectedValue(unexpectedError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("Unexpected error");
expect(logger.error).toHaveBeenCalled();
});
test("should handle empty surveys array", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
surveys: [],
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.surveys).toEqual([]);
});
test("should handle empty actionClasses array", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
actionClasses: [],
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.actionClasses).toEqual([]);
});
test("should transform surveys using transformPrismaSurvey", async () => {
const multipleSurveys = [
...mockEnvironmentData.surveys,
{
...mockEnvironmentData.surveys[0],
id: "survey-2",
name: "Second Survey",
},
];
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
surveys: multipleSurveys,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.surveys).toHaveLength(2);
});
test("should correctly map project properties to environment.project", async () => {
const customProject = {
...mockEnvironmentData.project,
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
placement: "center",
inAppSurveyBranding: false,
styling: { allowStyleOverwrite: true, brandColor: "#ff0000" },
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: customProject,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.project).toEqual({
id: "project-123",
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
placement: "center",
inAppSurveyBranding: false,
styling: { allowStyleOverwrite: true, brandColor: "#ff0000" },
});
});
test("should validate environmentId input", async () => {
// Invalid CUID should throw validation error
await expect(getEnvironmentStateData("invalid-id")).rejects.toThrow();
});
test("should handle different environment types", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
type: "development",
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.type).toBe("development");
});
test("should handle appSetupCompleted false", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
appSetupCompleted: false,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.appSetupCompleted).toBe(false);
});
test("should correctly extract organization billing data", async () => {
const customBilling = {
plan: "enterprise",
stripeCustomerId: "cus_123",
limits: {
monthly: { responses: 10000, miu: 50000 },
projects: 100,
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: {
id: "org-enterprise",
billing: customBilling,
},
},
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.organization).toEqual({
id: "org-enterprise",
billing: customBilling,
});
});
});

View File

@@ -10,7 +10,6 @@ import {
TJsEnvironmentStateSurvey,
} from "@formbricks/types/js";
import { validateInputs } from "@/lib/utils/validate";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
/**
@@ -55,7 +54,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: true,
recontactDays: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
placement: true,
inAppSurveyBranding: true,
styling: true,
@@ -175,17 +174,17 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: environmentData.project.id,
recontactDays: environmentData.project.recontactDays,
clickOutsideClose: environmentData.project.clickOutsideClose,
overlay: environmentData.project.overlay,
darkOverlay: environmentData.project.darkOverlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: resolveStorageUrlsInObject(environmentData.project.styling),
styling: environmentData.project.styling,
},
},
organization: {
id: environmentData.project.organization.id,
billing: environmentData.project.organization.billing,
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
surveys: transformedSurveys,
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
};
} catch (error) {

View File

@@ -58,7 +58,7 @@ const mockProject: TJsEnvironmentStateProject = {
inAppSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {
allowStyleOverwrite: false,
},

View File

@@ -0,0 +1,6 @@
import {
GET,
OPTIONS,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
export { GET, OPTIONS };

View File

@@ -1,15 +1,13 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -33,35 +31,6 @@ const handleDatabaseError = (error: Error, url: string, endpoint: string, respon
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
// Validate response data against validation rules
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const PUT = withV1ApiWrapper({
handler: async ({
req,
@@ -144,11 +113,6 @@ export const PUT = withV1ApiWrapper({
};
}
const validationResult = validateResponse(response, survey, inputValidation.data);
if (validationResult) {
return validationResult;
}
// update response with quota evaluation
let updatedResponse;
try {

View File

@@ -6,14 +6,12 @@ import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -35,26 +33,6 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) => {
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
@@ -145,11 +123,6 @@ export const POST = withV1ApiWrapper({
};
}
const validationResult = validateResponse(responseInputData, survey);
if (validationResult) {
return validationResult;
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInput["meta"] = {

View File

@@ -1,4 +1,4 @@
import { ImageResponse } from "next/og";
import { ImageResponse } from "@vercel/og";
import { NextRequest } from "next/server";
export const GET = async (req: NextRequest) => {

View File

@@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import {
ENCRYPTION_KEY,
NOTION_OAUTH_CLIENT_ID,
@@ -10,17 +10,10 @@ import {
WEBAPP_URL,
} from "@/lib/constants";
import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
handler: async ({ req }: { req: NextRequest }) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
@@ -33,13 +26,6 @@ export const GET = withV1ApiWrapper({
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
if (code && typeof code !== "string") {
return {
response: responses.badRequestResponse("`code` must be a string"),

View File

@@ -5,19 +5,12 @@ import {
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
handler: async ({ req }: { req: NextRequest }) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
@@ -30,13 +23,6 @@ export const GET = withV1ApiWrapper({
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
if (code && typeof code !== "string") {
return {
response: responses.badRequestResponse("`code` must be a string"),

View File

@@ -8,9 +8,8 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
async function fetchAndAuthorizeResponse(
@@ -57,10 +56,7 @@ export const GET = withV1ApiWrapper({
}
return {
response: responses.successResponse({
...result.response,
data: resolveStorageUrlsInObject(result.response.data),
}),
response: responses.successResponse(result.response),
};
} catch (error) {
return {
@@ -144,24 +140,6 @@ export const PUT = withV1ApiWrapper({
};
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
result.survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return {
@@ -192,7 +170,7 @@ export const PUT = withV1ApiWrapper({
}
return {
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
response: responses.successResponse(updated),
};
} catch (error) {
return {

View File

@@ -7,9 +7,8 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
createResponseWithQuotaEvaluation,
getResponses,
@@ -54,9 +53,7 @@ export const GET = withV1ApiWrapper({
allResponses.push(...environmentResponses);
}
return {
response: responses.successResponse(
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
),
response: responses.successResponse(allResponses),
};
} catch (error) {
if (error instanceof DatabaseError) {
@@ -152,24 +149,6 @@ export const POST = withV1ApiWrapper({
};
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
surveyResult.survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
if (responseInput.createdAt && !responseInput.updatedAt) {
responseInput.updatedAt = responseInput.createdAt;
}

View File

@@ -16,7 +16,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
const fetchAndAuthorizeSurvey = async (
surveyId: string,
@@ -59,18 +58,16 @@ export const GET = withV1ApiWrapper({
if (shouldTransformToQuestions) {
return {
response: responses.successResponse(
resolveStorageUrlsInObject({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
})
),
response: responses.successResponse({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
}),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
response: responses.successResponse(result.survey),
};
} catch (error) {
return {
@@ -205,12 +202,12 @@ export const PUT = withV1ApiWrapper({
};
return {
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
response: responses.successResponse(surveyWithQuestions),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
response: responses.successResponse(updatedSurvey),
};
} catch (error) {
return {

View File

@@ -14,7 +14,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
@@ -56,7 +55,7 @@ export const GET = withV1ApiWrapper({
});
return {
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
response: responses.successResponse(surveysWithQuestions),
};
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -0,0 +1,6 @@
import {
OPTIONS,
PUT,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
export { OPTIONS, PUT };

View File

@@ -0,0 +1,6 @@
import {
GET,
OPTIONS,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
export { GET, OPTIONS };

View File

@@ -11,7 +11,6 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -107,22 +106,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
survey.questions
);
if (validationErrors) {
return responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
);
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInputV2["meta"] = {

View File

@@ -3,9 +3,8 @@
// Error components must be Client components
import * as Sentry from "@sentry/nextjs";
import { TFunction } from "i18next";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { type ClientErrorType, getClientErrorData, isExpectedError } from "@formbricks/types/errors";
import { type ClientErrorType, getClientErrorData } from "@formbricks/types/errors";
import { Button } from "@/modules/ui/components/button";
import { ErrorComponent } from "@/modules/ui/components/error-component";
@@ -31,13 +30,11 @@ const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) =>
const errorData = getClientErrorData(error);
const { title, description } = getErrorMessages(errorData.type, t);
useEffect(() => {
if (process.env.NODE_ENV === "development") {
console.error(error.message);
} else if (!isExpectedError(error)) {
Sentry.captureException(error);
}
}, [error]);
if (process.env.NODE_ENV === "development") {
console.error(error.message);
} else {
Sentry.captureException(error);
}
return (
<div className="flex h-full w-full flex-col items-center justify-center">

View File

@@ -68,6 +68,7 @@ vi.mock("@/app/middleware/endpoint-validator", async () => {
isClientSideApiRoute: vi.fn().mockReturnValue({ isClientSideApi: false, isRateLimited: true }),
isManagementApiRoute: vi.fn().mockReturnValue({ isManagementApi: false, authenticationMethod: "apiKey" }),
isIntegrationRoute: vi.fn().mockReturnValue(false),
isSyncWithUserIdentificationEndpoint: vi.fn().mockReturnValue(null),
};
});
@@ -81,6 +82,7 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
syncUserIdentification: { windowMs: 60000, max: 50 },
},
},
}));
@@ -461,6 +463,45 @@ describe("withV1ApiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("handles sync user identification rate limiting", async () => {
const { applyRateLimit, applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
const {
isClientSideApiRoute,
isManagementApiRoute,
isIntegrationRoute,
isSyncWithUserIdentificationEndpoint,
} = await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(isSyncWithUserIdentificationEndpoint).mockReturnValue({
userId: "user-123",
environmentId: "env-123",
});
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
const rateLimitError = new Error("Sync rate limit exceeded");
rateLimitError.message = "Sync rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ url: "/api/v1/client/env-123/app/sync/user-123" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(applyRateLimit).toHaveBeenCalledWith(
expect.objectContaining({ windowMs: 60000, max: 50 }),
"user-123"
);
});
test("skips audit log creation when no action/targetType provided", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"

View File

@@ -10,6 +10,7 @@ import {
isClientSideApiRoute,
isIntegrationRoute,
isManagementApiRoute,
isSyncWithUserIdentificationEndpoint,
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -47,16 +48,23 @@ enum ApiV1RouteTypeEnum {
}
/**
* Apply client-side API rate limiting (IP-based)
* Apply client-side API rate limiting (IP-based or sync-specific)
*/
const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
const applyClientRateLimit = async (url: string, customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
const syncEndpoint = isSyncWithUserIdentificationEndpoint(url);
if (syncEndpoint) {
const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification;
await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId);
} else {
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
}
};
/**
* Handle rate limiting based on authentication and API type
*/
const handleRateLimiting = async (
url: string,
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum,
customRateLimitConfig?: TRateLimitConfig
@@ -76,7 +84,7 @@ const handleRateLimiting = async (
}
if (routeType === ApiV1RouteTypeEnum.Client) {
await applyClientRateLimit(customRateLimitConfig);
await applyClientRateLimit(url, customRateLimitConfig);
}
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
@@ -247,6 +255,7 @@ const getRouteType = (
* Features:
* - Performs authentication once and passes result to handler
* - Applies API key-based rate limiting with differentiated limits for client vs management APIs
* - Includes additional sync user identification rate limiting for client-side sync endpoints
* - Sets userId and organizationId in audit log automatically when audit logging is enabled
* - System and Sentry logs are always called for non-success responses
* - Uses function overloads to provide type safety without requiring type guards
@@ -319,7 +328,12 @@ export const withV1ApiWrapper: {
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
const rateLimitResponse = await handleRateLimiting(
req.nextUrl.pathname,
authentication,
routeType,
customRateLimitConfig
);
if (rateLimitResponse) return rateLimitResponse;
}

View File

@@ -4848,14 +4848,12 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
t("templates.preview_survey_question_2_choice_2_label"),
],
headline: t("templates.preview_survey_question_2_headline"),
subheader: t("templates.preview_survey_question_2_subheader"),
required: true,
shuffleOption: "none",
}),
isDraft: true,
},
],
buttonLabel: createI18nString(t("templates.next"), []),
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
},
{

View File

@@ -8,6 +8,7 @@ import {
isManagementApiRoute,
isPublicDomainRoute,
isRouteAllowedForDomain,
isSyncWithUserIdentificationEndpoint,
} from "./endpoint-validator";
describe("endpoint-validator", () => {
@@ -257,7 +258,6 @@ describe("endpoint-validator", () => {
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
expect(isAuthProtectedRoute("/")).toBe(false);
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
expect(isAuthProtectedRoute("/health")).toBe(false);
});
@@ -270,6 +270,58 @@ describe("endpoint-validator", () => {
});
});
describe("isSyncWithUserIdentificationEndpoint", () => {
test("should return environmentId and userId for valid sync URLs", () => {
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
expect(result1).toEqual({
environmentId: "env123",
userId: "user456",
});
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/abc-123/app/sync/xyz-789");
expect(result2).toEqual({
environmentId: "abc-123",
userId: "xyz-789",
});
const result3 = isSyncWithUserIdentificationEndpoint(
"/api/v1/client/env_123_test/app/sync/user_456_test"
);
expect(result3).toEqual({
environmentId: "env_123_test",
userId: "user_456_test",
});
});
test("should handle optional trailing slash", () => {
// Test both with and without trailing slash
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
expect(result1).toEqual({
environmentId: "env123",
userId: "user456",
});
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456/");
expect(result2).toEqual({
environmentId: "env123",
userId: "user456",
});
});
test("should return false for invalid sync URLs", () => {
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/other/user456")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v2/client/env123/app/sync/user456")).toBe(false); // only v1 supported
});
test("should handle empty or malformed IDs", () => {
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client//app/sync/user456")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/")).toBe(false);
});
});
describe("isPublicDomainRoute", () => {
test("should return true for health endpoint", () => {
expect(isPublicDomainRoute("/health")).toBe(true);
@@ -313,19 +365,6 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/c")).toBe(false);
expect(isPublicDomainRoute("/contact/token")).toBe(false);
});
test("should return true for pretty URL survey routes", () => {
expect(isPublicDomainRoute("/p/pretty123")).toBe(true);
expect(isPublicDomainRoute("/p/pretty-name-with-dashes")).toBe(true);
expect(isPublicDomainRoute("/p/survey_id_with_underscores")).toBe(true);
expect(isPublicDomainRoute("/p/abc123def456")).toBe(true);
});
test("should return false for malformed pretty URL survey routes", () => {
expect(isPublicDomainRoute("/p/")).toBe(false);
expect(isPublicDomainRoute("/p")).toBe(false);
expect(isPublicDomainRoute("/pretty/123")).toBe(false);
});
test("should return true for client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
@@ -389,8 +428,6 @@ describe("endpoint-validator", () => {
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
expect(isAdminDomainRoute("/p/pretty123")).toBe(false);
expect(isAdminDomainRoute("/p/pretty-name-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
});
@@ -406,7 +443,6 @@ describe("endpoint-validator", () => {
test("should allow public routes on public domain", () => {
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
@@ -443,8 +479,6 @@ describe("endpoint-validator", () => {
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
expect(isRouteAllowedForDomain("/p/pretty123", false)).toBe(false);
expect(isRouteAllowedForDomain("/p/pretty-name-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
});
@@ -459,8 +493,6 @@ describe("endpoint-validator", () => {
test("should handle paths with query parameters and fragments", () => {
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
});
@@ -471,7 +503,6 @@ describe("endpoint-validator", () => {
describe("URL parsing edge cases", () => {
test("should handle paths with query parameters", () => {
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
@@ -480,14 +511,12 @@ describe("endpoint-validator", () => {
test("should handle paths with fragments", () => {
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123#section")).toBe(true);
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
});
test("should handle trailing slashes", () => {
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
isManagementApi: true,
@@ -502,9 +531,6 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/preview")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/embed")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/thank-you")).toBe(true);
});
test("should handle nested client API routes", () => {
@@ -556,7 +582,12 @@ describe("endpoint-validator", () => {
test("should handle special characters in survey IDs", () => {
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true);
expect(
isSyncWithUserIdentificationEndpoint("/api/v1/client/env-123_test/app/sync/user-456_test")
).toEqual({
environmentId: "env-123_test",
userId: "user-456_test",
});
});
});
@@ -564,7 +595,6 @@ describe("endpoint-validator", () => {
test("should properly validate malicious or injection-like URLs", () => {
// SQL injection-like attempts
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
expect(isPublicDomainRoute("/p/'; DROP TABLE users; --")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
@@ -572,12 +602,10 @@ describe("endpoint-validator", () => {
// Path traversal attempts
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
// XSS-like attempts
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
expect(isPublicDomainRoute("/p/<script>alert('xss')</script>")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
isClientSideApi: true,
isRateLimited: true,
@@ -587,7 +615,6 @@ describe("endpoint-validator", () => {
test("should handle URL encoding", () => {
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
isManagementApi: true,
@@ -601,6 +628,15 @@ describe("endpoint-validator", () => {
const longSurveyId = "a".repeat(1000);
const longPath = `s/${longSurveyId}`;
expect(isPublicDomainRoute(`/${longPath}`)).toBe(true);
const longEnvironmentId = "env" + "a".repeat(1000);
const longUserId = "user" + "b".repeat(1000);
expect(
isSyncWithUserIdentificationEndpoint(`/api/v1/client/${longEnvironmentId}/app/sync/${longUserId}`)
).toEqual({
environmentId: longEnvironmentId,
userId: longUserId,
});
});
test("should handle empty and minimal inputs", () => {
@@ -615,6 +651,7 @@ describe("endpoint-validator", () => {
});
expect(isIntegrationRoute("")).toBe(false);
expect(isAuthProtectedRoute("")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("")).toBe(false);
});
});
@@ -623,7 +660,6 @@ describe("endpoint-validator", () => {
// These should not match due to case sensitivity
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
isClientSideApi: false,
isRateLimited: true,

View File

@@ -43,6 +43,14 @@ export const isAuthProtectedRoute = (url: string): boolean => {
return protectedRoutes.some((route) => url.startsWith(route));
};
export const isSyncWithUserIdentificationEndpoint = (
url: string
): { environmentId: string; userId: string } | false => {
const regex = /\/api\/v1\/client\/(?<environmentId>[^/]+)\/app\/sync\/(?<userId>[^/]+)/;
const match = url.match(regex);
return match ? { environmentId: match.groups!.environmentId, userId: match.groups!.userId } : false;
};
/**
* Check if the route should be accessible on the public domain (PUBLIC_URL)
* Uses whitelist approach - only explicitly allowed routes are accessible

View File

@@ -7,7 +7,6 @@ const PUBLIC_ROUTES = {
SURVEY_ROUTES: [
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
/^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
],
// API routes accessible from public domain

View File

@@ -2,6 +2,7 @@ import type { Session } from "next-auth";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import ClientEnvironmentRedirect from "@/app/ClientEnvironmentRedirect";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getIsFreshInstance } from "@/lib/instance/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -66,6 +67,10 @@ const Page = async () => {
if (!firstProductionEnvironmentId) {
if (isOwner || isManager) {
// On Cloud, show plan selection first for new users without any projects
if (IS_FORMBRICKS_CLOUD) {
return redirect(`/organizations/${userOrganizations[0].id}/workspaces/new/plan`);
}
return redirect(`/organizations/${userOrganizations[0].id}/workspaces/new/mode`);
} else {
return redirect(`/organizations/${userOrganizations[0].id}/landing`);

View File

@@ -8,7 +8,7 @@ import { authorizePrivateDownload } from "@/app/storage/[environmentId]/[accessT
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { deleteFile, getFileStreamForDownload } from "@/modules/storage/service";
import { deleteFile, getSignedUrlForDownload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { logFileDeletion } from "./lib/audit-logs";
@@ -39,25 +39,21 @@ export const GET = async (
}
}
// Stream the file directly
const streamResult = await getFileStreamForDownload(fileName, environmentId, accessType);
const signedUrlResult = await getSignedUrlForDownload(fileName, environmentId, accessType);
if (!streamResult.ok) {
const errorResponse = getErrorResponseFromStorageError(streamResult.error, { fileName });
if (!signedUrlResult.ok) {
const errorResponse = getErrorResponseFromStorageError(signedUrlResult.error, { fileName });
return errorResponse;
}
const { body, contentType, contentLength } = streamResult.data;
return new Response(body, {
status: 200,
return new Response(null, {
status: 302,
headers: {
"Content-Type": contentType,
...(contentLength > 0 && { "Content-Length": String(contentLength) }),
Location: signedUrlResult.data,
"Cache-Control":
accessType === "private"
? "no-store, no-cache, must-revalidate"
: "public, max-age=31536000, immutable",
: "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
},
});
};

View File

@@ -9,18 +9,17 @@
"source": "en-US",
"targets": [
"de-DE",
"es-ES",
"fr-FR",
"hu-HU",
"ja-JP",
"nl-NL",
"pt-BR",
"pt-PT",
"ro-RO",
"ru-RU",
"sv-SE",
"zh-Hans-CN",
"zh-Hant-TW"
"zh-Hant-TW",
"nl-NL",
"es-ES",
"sv-SE",
"ru-RU"
]
},
"version": 1.8

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
<svg width="101" height="22" viewBox="0 0 101 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.0582 20.817C4.32115 20.817 0 16.2763 0 10.6704C0 5.04589 4.1005 0.467773 10.0582 0.467773C13.2209 0.467773 15.409 1.43945 17.1191 3.66311L14.3609 5.96151C13.2025 4.72822 11.805 4.11158 10.0582 4.11158C6.17833 4.11158 4.04533 7.08268 4.04533 10.6704C4.04533 14.2582 6.38059 17.1732 10.0582 17.1732C11.7866 17.1732 13.2577 16.5566 14.4161 15.3233L17.1375 17.7151C15.501 19.8453 13.2577 20.817 10.0582 20.817Z" fill="#292929"/>
<path d="M29.0161 5.88601H32.7304V20.4612H29.0161V18.331C28.2438 19.8446 26.9566 20.8536 24.4927 20.8536C20.5577 20.8536 17.4133 17.4341 17.4133 13.2297C17.4133 9.02528 20.5577 5.60571 24.4927 5.60571C26.9383 5.60571 28.2438 6.61477 29.0161 8.12835V5.88601ZM29.1264 13.2297C29.1264 10.95 27.5634 9.06266 25.0995 9.06266C22.7274 9.06266 21.1828 10.9686 21.1828 13.2297C21.1828 15.4346 22.7274 17.3967 25.0995 17.3967C27.5451 17.3967 29.1264 15.4907 29.1264 13.2297Z" fill="#292929"/>
<path d="M35.3599 0H39.0742V20.4427H35.3599V0Z" fill="#292929"/>
<path d="M40.7291 18.5182C40.7291 17.3223 41.6853 16.3132 42.9908 16.3132C44.2964 16.3132 45.2158 17.3223 45.2158 18.5182C45.2158 19.7515 44.278 20.7605 42.9908 20.7605C41.7037 20.7605 40.7291 19.7515 40.7291 18.5182Z" fill="#292929"/>
<path d="M59.4296 18.1068C58.0505 19.7885 55.9543 20.8536 53.4719 20.8536C49.0404 20.8536 45.7858 17.4341 45.7858 13.2297C45.7858 9.02528 49.0404 5.60571 53.4719 5.60571C55.8623 5.60571 57.9402 6.61477 59.3193 8.20309L56.4508 10.6136C55.7336 9.71667 54.7958 9.04397 53.4719 9.04397C51.0999 9.04397 49.5553 10.95 49.5553 13.211C49.5553 15.472 51.0999 17.378 53.4719 17.378C54.9062 17.378 55.8991 16.6306 56.6346 15.6215L59.4296 18.1068Z" fill="#292929"/>
<path d="M59.7422 13.2297C59.7422 9.02528 62.9968 5.60571 67.4283 5.60571C71.8598 5.60571 75.1144 9.02528 75.1144 13.2297C75.1144 17.4341 71.8598 20.8536 67.4283 20.8536C62.9968 20.8349 59.7422 17.4341 59.7422 13.2297ZM71.3449 13.2297C71.3449 10.95 69.8003 9.06266 67.4283 9.06266C65.0563 9.04397 63.5117 10.95 63.5117 13.2297C63.5117 15.4907 65.0563 17.3967 67.4283 17.3967C69.8003 17.3967 71.3449 15.4907 71.3449 13.2297Z" fill="#292929"/>
<path d="M100.232 11.5482V20.4428H96.518V12.4638C96.518 9.94119 95.3412 8.85739 93.576 8.85739C91.921 8.85739 90.7442 9.67958 90.7442 12.4638V20.4428H87.0299V12.4638C87.0299 9.94119 85.8346 8.85739 84.0878 8.85739C82.4329 8.85739 80.9802 9.67958 80.9802 12.4638V20.4428H77.2659V5.8676H80.9802V7.88571C81.7525 6.31607 83.15 5.53125 85.3014 5.53125C87.3425 5.53125 89.0525 6.5403 89.9903 8.24074C90.9281 6.50293 92.3072 5.53125 94.8079 5.53125C97.8603 5.54994 100.232 7.86702 100.232 11.5482Z" fill="#292929"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 948 299.3" style="enable-background:new 0 0 948 299.3;" xml:space="preserve">
<style type="text/css">
.st0{fill:#73D700;}
.st1{fill:#FFFFFF;}
</style>
<g id="BG">
<rect class="st0" width="948" height="299.3"/>
</g>
<g id="Logo">
<rect x="81.7" y="149.4" class="st1" width="2.5" height="0.8"/>
<g>
<path class="st1" d="M94.7,82c-7.2,0-13,5.8-13,13v122.5h34.7v-53.2h49.1c7.1,0,13-5.9,13-13v-14.7h-62.1v-20.4
c0-3.6,2.9-6.5,6.5-6.5h58.8c7.1,0,13-5.8,13-13V82L94.7,82L94.7,82z"/>
<path class="st1" d="M250.7,189.6c-3.6,0-6.5-2.9-6.5-6.5V95.1c0-7.2-5.9-13.1-13.1-13.1h-21.8v122.4c0,7.2,5.9,13.1,13.1,13.1
h71.3c7.2,0,13.1-5.9,13.1-13.1v-14.8H250.7L250.7,189.6z"/>
<path class="st1" d="M356.7,217.3H322v-70.7c0-7.2,5.9-13,13-13h21.7L356.7,217.3L356.7,217.3z"/>
<path class="st1" d="M343.7,121.1H322V95.1c0-7.2,5.9-13,13-13h8.7c7.2,0,13,5.9,13,13v13C356.7,115.2,350.8,121.1,343.7,121.1"/>
<path class="st1" d="M580.4,195.4h-23.9c-6.9,0-12.5-5.6-12.5-12.5v-22.7h36.2c9.5,0,17.2,7.9,17.2,17.6S589.8,195.4,580.4,195.4
M543.9,104h32.5c8.6,0,15.5,7,15.5,15.7s-6.8,15.6-15.3,15.7h-21.5c-6.2,0-11.2-5-11.2-11.2L543.9,104L543.9,104z M617.5,150.7
c-0.8-0.7-2.9-2.4-3.4-2.8c6.5-6.6,9.2-15.4,9.2-26.6c0-24.7-16-39.3-40.7-39.3h-53.4c-7.1,0-13,5.8-13,13v109.4
c0,7.1,5.8,13,13,13h59.5c24.7,0,39.7-14.4,39.7-39.1C628.3,166.7,624.3,157.4,617.5,150.7"/>
<path class="st1" d="M752.5,82.1H737c-7.1,0-13,5.8-13,13V175c0,11.7-8,19.5-22,19.5h-6.4c-13.9,0-22-7.8-22-19.5V82.1h-15.5
c-7.1,0-13,5.8-13,13v84.2c0,24.2,16.6,40.3,45.3,40.3h16.6c28.7,0,45.3-16.1,45.3-40.3L752.5,82.1L752.5,82.1z"/>
<path class="st1" d="M810.1,109.8h43.8c7.1,0,13-5.8,13-13V82h-56.8c-22.7,0.2-41,18.7-41,41.4s18,39.6,40.4,40.1l0,0l17.9,0h0
c7.2,0.1,13,5.9,13,13.1s-5.8,13-12.9,13.1h-56.7v14.7c0,7.1,5.8,13,13,13h44c22.5-0.4,40.7-18.8,40.7-41.4s-17.8-39.4-40.1-40.1
v0h-18.2c-7.2-0.1-13-5.9-13-13.1S802.9,109.8,810.1,109.8"/>
<path class="st1" d="M489,193.8l-23.7-32.6l-20.4,28.1l17.4,23.9c5.2,7.2,15.5,8.9,22.7,3.6l0.4-0.3
C492.6,211.2,494.2,201,489,193.8"/>
<path class="st1" d="M457.1,149.9l-20.4-28.1l-25.6-35.2c-5.2-7.2-15.5-8.8-22.7-3.6l-0.4,0.3c-7.2,5.2-8.9,15.5-3.6,22.7
l31.8,43.8l-31.8,43.9c-5.3,7.3-3.7,17.5,3.5,22.8l0.4,0.3c7.2,5.2,17.5,3.6,22.7-3.6l18.8-25.8L457.1,149.9L457.1,149.9z"/>
<path class="st1" d="M485.4,83.3L485,83c-7.2-5.2-17.5-3.6-22.7,3.6l-17.4,23.9l20.4,28.1L489,106
C494.2,98.8,492.6,88.6,485.4,83.3"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -1,205 +1,59 @@
// OpenTelemetry instrumentation for Next.js - loaded via instrumentation.ts hook
// Pattern based on: ee/src/opentelemetry.ts (license server)
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
// instrumentation-node.ts
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { HostMetrics } from "@opentelemetry/host-metrics";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";
import {
AlwaysOffSampler,
AlwaysOnSampler,
BatchSpanProcessor,
ParentBasedSampler,
type Sampler,
TraceIdRatioBasedSampler,
} from "@opentelemetry/sdk-trace-base";
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
import { PrismaInstrumentation } from "@prisma/instrumentation";
detectResources,
envDetector,
hostDetector,
processDetector,
resourceFromAttributes,
} from "@opentelemetry/resources";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { logger } from "@formbricks/logger";
import { env } from "@/lib/env";
// --- Configuration from environment ---
const serviceName = process.env.OTEL_SERVICE_NAME || "formbricks";
const serviceVersion = process.env.npm_package_version || "0.0.0";
const environment = process.env.ENVIRONMENT || process.env.NODE_ENV || "development";
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
const prometheusEnabled = process.env.PROMETHEUS_ENABLED === "1";
const prometheusPort = process.env.PROMETHEUS_EXPORTER_PORT
? Number.parseInt(process.env.PROMETHEUS_EXPORTER_PORT)
: 9464;
// --- Configure OTLP exporters (conditional on endpoint being set) ---
let traceExporter: OTLPTraceExporter | undefined;
let otlpMetricExporter: OTLPMetricExporter | undefined;
if (otlpEndpoint) {
try {
// OTLPTraceExporter reads OTEL_EXPORTER_OTLP_ENDPOINT from env
// and appends /v1/traces for HTTP transport
// Uses OTEL_EXPORTER_OTLP_HEADERS from env natively (W3C OTel format: key=value,key2=value2)
traceExporter = new OTLPTraceExporter();
// OTLPMetricExporter reads OTEL_EXPORTER_OTLP_ENDPOINT from env
// and appends /v1/metrics for HTTP transport
// Uses OTEL_EXPORTER_OTLP_HEADERS from env natively
otlpMetricExporter = new OTLPMetricExporter();
} catch (error) {
logger.error(error, "Failed to create OTLP exporters. Telemetry will not be exported.");
}
}
// --- Configure Prometheus exporter (pull-based metrics for ServiceMonitor) ---
let prometheusExporter: PrometheusExporter | undefined;
if (prometheusEnabled) {
prometheusExporter = new PrometheusExporter({
port: prometheusPort,
endpoint: "/metrics",
host: "0.0.0.0",
});
}
// --- Build metric readers array ---
const metricReaders: (PeriodicExportingMetricReader | PrometheusExporter)[] = [];
if (otlpMetricExporter) {
metricReaders.push(
new PeriodicExportingMetricReader({
exporter: otlpMetricExporter,
exportIntervalMillis: 60000, // Export every 60 seconds
})
);
}
if (prometheusExporter) {
metricReaders.push(prometheusExporter);
}
// --- Resource attributes ---
const resourceAttributes: Record<string, string> = {
[ATTR_SERVICE_NAME]: serviceName,
[ATTR_SERVICE_VERSION]: serviceVersion,
"deployment.environment": environment,
};
// --- Configure sampler ---
const samplerType = process.env.OTEL_TRACES_SAMPLER || "always_on";
const parsedSamplerArg = process.env.OTEL_TRACES_SAMPLER_ARG
? Number.parseFloat(process.env.OTEL_TRACES_SAMPLER_ARG)
: undefined;
const samplerArg =
parsedSamplerArg !== undefined && !Number.isNaN(parsedSamplerArg) ? parsedSamplerArg : undefined;
let sampler: Sampler;
switch (samplerType) {
case "always_on":
sampler = new AlwaysOnSampler();
break;
case "always_off":
sampler = new AlwaysOffSampler();
break;
case "traceidratio":
sampler = new TraceIdRatioBasedSampler(samplerArg ?? 1);
break;
case "parentbased_traceidratio":
sampler = new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(samplerArg ?? 1),
});
break;
case "parentbased_always_on":
sampler = new ParentBasedSampler({
root: new AlwaysOnSampler(),
});
break;
case "parentbased_always_off":
sampler = new ParentBasedSampler({
root: new AlwaysOffSampler(),
});
break;
default:
logger.warn(`Unknown sampler type: ${samplerType}. Using always_on.`);
sampler = new AlwaysOnSampler();
}
// --- Initialize NodeSDK ---
const sdk = new NodeSDK({
sampler,
resource: resourceFromAttributes(resourceAttributes),
// When no OTLP endpoint is configured (e.g. Prometheus-only setups), pass an empty
// spanProcessors array to prevent the SDK from falling back to its default OTLP exporter
// which would attempt connections to localhost:4318 and cause noisy errors.
spanProcessors: traceExporter
? [
new BatchSpanProcessor(traceExporter, {
maxQueueSize: 2048,
maxExportBatchSize: 512,
scheduledDelayMillis: 5000,
exportTimeoutMillis: 30000,
}),
]
: [],
metricReaders: metricReaders.length > 0 ? metricReaders : undefined,
instrumentations: [
getNodeAutoInstrumentations({
// Disable noisy/unnecessary instrumentations
"@opentelemetry/instrumentation-fs": {
enabled: false,
},
"@opentelemetry/instrumentation-dns": {
enabled: false,
},
"@opentelemetry/instrumentation-net": {
enabled: false,
},
// Disable pg instrumentation - PrismaInstrumentation handles DB tracing
"@opentelemetry/instrumentation-pg": {
enabled: false,
},
"@opentelemetry/instrumentation-http": {
// Ignore health/metrics endpoints to reduce noise
ignoreIncomingRequestHook: (req) => {
const url = req.url || "";
return url === "/health" || url.startsWith("/metrics") || url === "/api/v2/health";
},
},
// Enable runtime metrics for Node.js process monitoring
"@opentelemetry/instrumentation-runtime-node": {
enabled: true,
},
}),
// Prisma instrumentation for database query tracing
new PrismaInstrumentation(),
],
const exporter = new PrometheusExporter({
port: env.PROMETHEUS_EXPORTER_PORT ? parseInt(env.PROMETHEUS_EXPORTER_PORT) : 9464,
endpoint: "/metrics",
host: "0.0.0.0", // Listen on all network interfaces
});
// Start the SDK
sdk.start();
const detectedResources = detectResources({
detectors: [envDetector, processDetector, hostDetector],
});
// --- Log initialization status ---
const enabledFeatures: string[] = [];
if (traceExporter) enabledFeatures.push("traces");
if (otlpMetricExporter) enabledFeatures.push("otlp-metrics");
if (prometheusExporter) enabledFeatures.push("prometheus-metrics");
const customResources = resourceFromAttributes({});
const samplerArgStr = process.env.OTEL_TRACES_SAMPLER_ARG || "";
const samplerArgMsg = samplerArgStr ? `, samplerArg=${samplerArgStr}` : "";
const resources = detectedResources.merge(customResources);
if (enabledFeatures.length > 0) {
logger.info(
`OpenTelemetry initialized: service=${serviceName}, version=${serviceVersion}, environment=${environment}, exporters=${enabledFeatures.join("+")}, sampler=${samplerType}${samplerArgMsg}`
);
} else {
logger.info(
`OpenTelemetry initialized (no exporters): service=${serviceName}, version=${serviceVersion}, environment=${environment}`
);
}
const meterProvider = new MeterProvider({
readers: [exporter],
resource: resources,
});
// --- Graceful shutdown ---
// Run before other SIGTERM listeners (logger flush, etc.) so spans are drained first.
process.prependListener("SIGTERM", async () => {
const hostMetrics = new HostMetrics({
name: `otel-metrics`,
meterProvider,
});
registerInstrumentations({
meterProvider,
instrumentations: [new HttpInstrumentation(), new RuntimeNodeInstrumentation()],
});
hostMetrics.start();
process.on("SIGTERM", async () => {
try {
await sdk.shutdown();
// Stop collecting metrics or flush them if needed
await meterProvider.shutdown();
// Possibly close other instrumentation resources
} catch (e) {
logger.error(e, "Error during OpenTelemetry shutdown");
logger.error(e, "Error during graceful shutdown");
} finally {
process.exit(0);
}
});

View File

@@ -5,13 +5,10 @@ export const onRequestError = Sentry.captureRequestError;
export const register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs") {
// Load OpenTelemetry instrumentation when Prometheus metrics or OTLP export is enabled
if (PROMETHEUS_ENABLED || process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
if (PROMETHEUS_ENABLED) {
await import("./instrumentation-node");
}
}
// Sentry init loads after OTEL to avoid TracerProvider conflicts
// Sentry tracing is disabled (tracesSampleRate: 0) -- SigNoz handles distributed tracing
if (process.env.NEXT_RUNTIME === "nodejs" && IS_PRODUCTION && SENTRY_DSN) {
await import("./sentry.server.config");
}

View File

@@ -165,20 +165,19 @@ export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
export const DEFAULT_LOCALE = "en-US";
export const AVAILABLE_LOCALES: TUserLocale[] = [
"de-DE",
"en-US",
"es-ES",
"fr-FR",
"hu-HU",
"ja-JP",
"nl-NL",
"de-DE",
"pt-BR",
"fr-FR",
"nl-NL",
"zh-Hant-TW",
"pt-PT",
"ro-RO",
"ru-RU",
"sv-SE",
"ja-JP",
"zh-Hans-CN",
"zh-Hant-TW",
"es-ES",
"sv-SE",
"ru-RU",
];
// Billing constants

View File

@@ -55,6 +55,7 @@ export const env = createEnv({
OIDC_DISPLAY_NAME: z.string().optional(),
OIDC_ISSUER: z.string().optional(),
OIDC_SIGNING_ALGORITHM: z.string().optional(),
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
REDIS_URL:
process.env.NODE_ENV === "test"
? z.string().optional()
@@ -173,6 +174,7 @@ export const env = createEnv({
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,

View File

@@ -167,12 +167,6 @@ export const createEnvironment = async (
description: "Your contact's last name",
type: "default",
},
{
key: "language",
name: "Language",
description: "The language preference of a contact",
type: "default",
},
],
},
},

View File

@@ -1,6 +0,0 @@
/**
* Error codes returned by Google Sheets integration.
* Use these constants when comparing error responses to avoid typos and enable reuse.
*/
export const GOOGLE_SHEET_INTEGRATION_INVALID_GRANT = "invalid_grant";
export const GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION = "insufficient_permission";

View File

@@ -2,12 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { ZString } from "@formbricks/types/common";
import {
AuthenticationError,
DatabaseError,
OperationNotAllowedError,
UnknownError,
} from "@formbricks/types/errors";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import {
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
@@ -16,12 +11,8 @@ import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
GOOGLE_SHEET_MESSAGE_LIMIT,
} from "@/lib/constants";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { GOOGLE_SHEET_MESSAGE_LIMIT } from "@/lib/constants";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { truncateText } from "../utils/strings";
import { validateInputs } from "../utils/validate";
@@ -90,17 +81,6 @@ export const writeData = async (
}
};
export const validateGoogleSheetsConnection = async (
googleSheetIntegrationData: TIntegrationGoogleSheets
): Promise<void> => {
validateInputs([googleSheetIntegrationData, ZIntegrationGoogleSheets]);
const integrationData = structuredClone(googleSheetIntegrationData);
integrationData.config.data.forEach((data) => {
data.createdAt = new Date(data.createdAt);
});
await authorize(integrationData);
};
export const getSpreadsheetNameById = async (
googleSheetIntegrationData: TIntegrationGoogleSheets,
spreadsheetId: string
@@ -114,17 +94,7 @@ export const getSpreadsheetNameById = async (
return new Promise((resolve, reject) => {
sheets.spreadsheets.get({ spreadsheetId }, (err, response) => {
if (err) {
const msg = err.message?.toLowerCase() ?? "";
const isPermissionError =
msg.includes("permission") ||
msg.includes("caller does not have") ||
msg.includes("insufficient permission") ||
msg.includes("access denied");
if (isPermissionError) {
reject(new OperationNotAllowedError(GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION));
} else {
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
}
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
return;
}
const spreadsheetTitle = response.data.properties.title;
@@ -139,70 +109,26 @@ export const getSpreadsheetNameById = async (
}
};
const isInvalidGrantError = (error: unknown): boolean => {
const err = error as { message?: string; response?: { data?: { error?: string } } };
return (
typeof err?.message === "string" &&
err.message.toLowerCase().includes(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT)
);
};
/** Buffer in ms before expiry_date to consider token near-expired (5 minutes). */
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
const GOOGLE_TOKENINFO_URL = "https://www.googleapis.com/oauth2/v1/tokeninfo";
/**
* Verifies that the access token is still valid and not revoked (e.g. user removed app access).
* Returns true if token is valid, false if invalid/revoked.
*/
const isAccessTokenValid = async (accessToken: string): Promise<boolean> => {
try {
const res = await fetch(`${GOOGLE_TOKENINFO_URL}?access_token=${encodeURIComponent(accessToken)}`);
return res.ok;
} catch {
return false;
}
};
const authorize = async (googleSheetIntegrationData: TIntegrationGoogleSheets) => {
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const key = googleSheetIntegrationData.config.key;
const refresh_token = googleSheetIntegrationData.config.key.refresh_token;
oAuth2Client.setCredentials({
refresh_token,
});
const { credentials } = await oAuth2Client.refreshAccessToken();
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
type: "googleSheets",
config: {
data: googleSheetIntegrationData.config?.data ?? [],
email: googleSheetIntegrationData.config?.email ?? "",
key: credentials,
},
});
const hasStoredCredentials =
key.access_token && key.expiry_date && key.expiry_date > Date.now() + TOKEN_EXPIRY_BUFFER_MS;
oAuth2Client.setCredentials(credentials);
if (hasStoredCredentials && (await isAccessTokenValid(key.access_token))) {
oAuth2Client.setCredentials(key);
return oAuth2Client;
}
oAuth2Client.setCredentials({ refresh_token: key.refresh_token });
try {
const { credentials } = await oAuth2Client.refreshAccessToken();
const mergedCredentials = {
...credentials,
refresh_token: credentials.refresh_token ?? key.refresh_token,
};
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
type: "googleSheets",
config: {
data: googleSheetIntegrationData.config?.data ?? [],
email: googleSheetIntegrationData.config?.email ?? "",
key: mergedCredentials,
},
});
oAuth2Client.setCredentials(mergedCredentials);
return oAuth2Client;
} catch (error) {
if (isInvalidGrantError(error)) {
throw new AuthenticationError(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT);
}
throw error;
}
return oAuth2Client;
};

View File

@@ -126,12 +126,6 @@ export const addMultiLanguageLabels = (object: unknown, languageSymbols: string[
};
export const appLanguages = [
{
code: "de-DE",
label: {
"en-US": "German",
},
},
{
code: "en-US",
label: {
@@ -139,9 +133,15 @@ export const appLanguages = [
},
},
{
code: "es-ES",
code: "de-DE",
label: {
"en-US": "Spanish",
"en-US": "German",
},
},
{
code: "pt-BR",
label: {
"en-US": "Portuguese (Brazil)",
},
},
{
@@ -151,27 +151,9 @@ export const appLanguages = [
},
},
{
code: "hu-HU",
code: "zh-Hant-TW",
label: {
"en-US": "Hungarian",
},
},
{
code: "ja-JP",
label: {
"en-US": "Japanese",
},
},
{
code: "nl-NL",
label: {
"en-US": "Dutch",
},
},
{
code: "pt-BR",
label: {
"en-US": "Portuguese (Brazil)",
"en-US": "Chinese (Traditional)",
},
},
{
@@ -187,15 +169,9 @@ export const appLanguages = [
},
},
{
code: "ru-RU",
code: "ja-JP",
label: {
"en-US": "Russian",
},
},
{
code: "sv-SE",
label: {
"en-US": "Swedish",
"en-US": "Japanese",
},
},
{
@@ -205,9 +181,27 @@ export const appLanguages = [
},
},
{
code: "zh-Hant-TW",
code: "nl-NL",
label: {
"en-US": "Chinese (Traditional)",
"en-US": "Dutch",
},
},
{
code: "es-ES",
label: {
"en-US": "Spanish",
},
},
{
code: "sv-SE",
label: {
"en-US": "Swedish",
},
},
{
code: "ru-RU",
label: {
"en-US": "Russian",
},
},
];

View File

@@ -48,7 +48,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -106,7 +106,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -171,7 +171,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -196,7 +196,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -250,7 +250,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -324,7 +324,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -378,7 +378,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -403,7 +403,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -448,7 +448,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,

View File

@@ -22,7 +22,7 @@ const selectProject = {
config: true,
placement: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
environments: true,
styling: true,
logo: true,

View File

@@ -22,7 +22,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { ITEMS_PER_PAGE } from "../constants";
@@ -409,10 +408,9 @@ export const getResponseDownloadFile = async (
if (survey.isVerifyEmailEnabled) {
headers.push("Verified Email");
}
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
const jsonData = getResponsesJson(
survey,
resolvedResponses,
responses,
elements,
userAttributes,
hiddenFields,

View File

@@ -1,6 +1,5 @@
// https://github.com/airbnb/javascript/#naming--uppercase
import { TProjectStyling } from "@formbricks/types/project";
import { isLight, mixColor } from "@/lib/utils/colors";
export const COLOR_DEFAULTS = {
brandColor: "#64748b",
@@ -12,210 +11,32 @@ export const COLOR_DEFAULTS = {
highlightBorderColor: "#64748b",
} as const;
const DEFAULT_BRAND_COLOR = "#64748b";
/**
* Derives a complete set of suggested color values from a single brand color.
*
* Used by the project-level "Suggest Colors" button **and** to build
* `STYLE_DEFAULTS` so that a fresh install always has colours that are
* visually cohesive with the default brand.
*
* The returned object is a flat map of form-field paths to values so it
* can be spread directly into form defaults or applied via `form.setValue`.
*/
export const getSuggestedColors = (brandColor: string = DEFAULT_BRAND_COLOR) => {
// Question / dark text: brand darkened with black (visible brand tint)
const questionColor = mixColor(brandColor, "#000000", 0.35);
// Input / option background: white with noticeable brand tint
const inputBg = mixColor(brandColor, "#ffffff", 0.92);
// Input border: visible brand-tinted border
const inputBorder = mixColor(brandColor, "#ffffff", 0.6);
// Card tones
const cardBg = mixColor(brandColor, "#ffffff", 0.97);
const cardBorder = mixColor(brandColor, "#ffffff", 0.8);
// Page background
const pageBg = mixColor(brandColor, "#ffffff", 0.855);
return {
// General
"brandColor.light": brandColor,
"questionColor.light": questionColor,
// Headlines & Descriptions — use questionColor to match the legacy behaviour
// where all text elements derived their color from questionColor.
"elementHeadlineColor.light": questionColor,
"elementDescriptionColor.light": questionColor,
"elementUpperLabelColor.light": questionColor,
// Buttons — use the brand color so the button matches the user's intent.
"buttonBgColor.light": brandColor,
"buttonTextColor.light": isLight(brandColor) ? "#0f172a" : "#ffffff",
// Inputs
"inputColor.light": inputBg,
"inputBorderColor.light": inputBorder,
"inputTextColor.light": questionColor,
// Options (Radio / Checkbox)
"optionBgColor.light": inputBg,
"optionLabelColor.light": questionColor,
// Card
"cardBackgroundColor.light": cardBg,
"cardBorderColor.light": cardBorder,
// Highlight / Focus
"highlightBorderColor.light": mixColor(brandColor, "#ffffff", 0.25),
// Progress Bar — indicator uses the brand color; track is a lighter tint.
"progressIndicatorBgColor.light": brandColor,
"progressTrackBgColor.light": mixColor(brandColor, "#ffffff", 0.8),
// Background
background: { bg: pageBg, bgType: "color" as const, brightness: 100 },
};
};
// Pre-compute colors derived from the default brand color.
const _colors = getSuggestedColors(DEFAULT_BRAND_COLOR);
/**
* Single source of truth for every styling default.
*
* Color values are derived from the default brand color (#64748b) via
* `getSuggestedColors()`. Non-color values (dimensions, weights, sizes)
* are hardcoded here and must be kept in sync with globals.css.
*
* Used everywhere: form defaults, preview rendering, email templates,
* and as the reset target for "Restore defaults".
*/
export const STYLE_DEFAULTS: TProjectStyling = {
export const defaultStyling: TProjectStyling = {
allowStyleOverwrite: true,
brandColor: { light: _colors["brandColor.light"] },
questionColor: { light: _colors["questionColor.light"] },
inputColor: { light: _colors["inputColor.light"] },
inputBorderColor: { light: _colors["inputBorderColor.light"] },
cardBackgroundColor: { light: _colors["cardBackgroundColor.light"] },
cardBorderColor: { light: _colors["cardBorderColor.light"] },
brandColor: {
light: COLOR_DEFAULTS.brandColor,
},
questionColor: {
light: COLOR_DEFAULTS.questionColor,
},
inputColor: {
light: COLOR_DEFAULTS.inputColor,
},
inputBorderColor: {
light: COLOR_DEFAULTS.inputBorderColor,
},
cardBackgroundColor: {
light: COLOR_DEFAULTS.cardBackgroundColor,
},
cardBorderColor: {
light: COLOR_DEFAULTS.cardBorderColor,
},
isLogoHidden: false,
highlightBorderColor: { light: _colors["highlightBorderColor.light"] },
highlightBorderColor: undefined,
isDarkModeEnabled: false,
roundness: 8,
cardArrangement: { linkSurveys: "simple", appSurveys: "simple" },
// Headlines & Descriptions
elementHeadlineColor: { light: _colors["elementHeadlineColor.light"] },
elementHeadlineFontSize: 16,
elementHeadlineFontWeight: 600,
elementDescriptionColor: { light: _colors["elementDescriptionColor.light"] },
elementDescriptionFontSize: 14,
elementDescriptionFontWeight: 400,
elementUpperLabelColor: { light: _colors["elementUpperLabelColor.light"] },
elementUpperLabelFontSize: 12,
elementUpperLabelFontWeight: 400,
// Inputs
inputTextColor: { light: _colors["inputTextColor.light"] },
inputBorderRadius: 8,
inputHeight: 20,
inputFontSize: 14,
inputPaddingX: 8,
inputPaddingY: 8,
inputPlaceholderOpacity: 0.5,
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
// Buttons
buttonBgColor: { light: _colors["buttonBgColor.light"] },
buttonTextColor: { light: _colors["buttonTextColor.light"] },
buttonBorderRadius: 8,
buttonHeight: "auto",
buttonFontSize: 16,
buttonFontWeight: 500,
buttonPaddingX: 12,
buttonPaddingY: 12,
// Options
optionBgColor: { light: _colors["optionBgColor.light"] },
optionLabelColor: { light: _colors["optionLabelColor.light"] },
optionBorderRadius: 8,
optionPaddingX: 16,
optionPaddingY: 16,
optionFontSize: 14,
// Progress Bar
progressTrackHeight: 8,
progressTrackBgColor: { light: _colors["progressTrackBgColor.light"] },
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
};
/**
* Fills in new v4.7 color fields from legacy v4.6 fields when they are missing.
*
* v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor.
* v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc.
*
* When loading v4.6 data the new fields are absent. Without this helper the
* form would fall back to STYLE_DEFAULTS (derived from the *default* brand
* colour), causing a visible mismatch. This function derives the new fields
* from the actually-saved legacy fields so the preview and form stay coherent.
*
* Only sets a field when the legacy source exists AND the new field is absent.
*/
export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Record<string, unknown> => {
const light = (key: string): string | undefined =>
(saved[key] as { light?: string } | null | undefined)?.light;
const q = light("questionColor");
const b = light("brandColor");
const i = light("inputColor");
return {
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }),
...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }),
...(q && !saved.inputTextColor && { inputTextColor: { light: q } }),
...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }),
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
...(b &&
!saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
};
};
/**
* Builds a complete TProjectStyling object from a single brand color.
*
* Uses STYLE_DEFAULTS for all non-color properties (dimensions, weights, etc.)
* and derives every color from the given brand color via getSuggestedColors().
*
* Useful when only a brand color is known (e.g. onboarding) and a fully
* coherent styling object is needed for both preview rendering and persistence.
*/
export const buildStylingFromBrandColor = (brandColor: string = DEFAULT_BRAND_COLOR): TProjectStyling => {
const colors = getSuggestedColors(brandColor);
return {
...STYLE_DEFAULTS,
brandColor: { light: colors["brandColor.light"] },
questionColor: { light: colors["questionColor.light"] },
elementHeadlineColor: { light: colors["elementHeadlineColor.light"] },
elementDescriptionColor: { light: colors["elementDescriptionColor.light"] },
elementUpperLabelColor: { light: colors["elementUpperLabelColor.light"] },
buttonBgColor: { light: colors["buttonBgColor.light"] },
buttonTextColor: { light: colors["buttonTextColor.light"] },
inputColor: { light: colors["inputColor.light"] },
inputBorderColor: { light: colors["inputBorderColor.light"] },
inputTextColor: { light: colors["inputTextColor.light"] },
optionBgColor: { light: colors["optionBgColor.light"] },
optionLabelColor: { light: colors["optionLabelColor.light"] },
cardBackgroundColor: { light: colors["cardBackgroundColor.light"] },
cardBorderColor: { light: colors["cardBorderColor.light"] },
highlightBorderColor: { light: colors["highlightBorderColor.light"] },
progressIndicatorBgColor: { light: colors["progressIndicatorBgColor.light"] },
progressTrackBgColor: { light: colors["progressTrackBgColor.light"] },
background: colors.background,
};
cardArrangement: {
linkSurveys: "straight",
appSurveys: "straight",
},
};

View File

@@ -85,7 +85,7 @@ export const mockProject: TProject = {
inAppSurveyBranding: false,
placement: "bottomRight",
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
environments: [],
languages: [],
config: {
@@ -168,7 +168,6 @@ export const mockContactAttributeKey: TContactAttributeKey = {
type: "custom",
description: "mock action class",
isUnique: false,
dataType: "string",
...commonMockProperties,
};

View File

@@ -141,68 +141,5 @@ describe("Time Utilities", () => {
expect(convertDatesInObject("string")).toBe("string");
expect(convertDatesInObject(123)).toBe(123);
});
test("should not convert dates in ignored keys when keysToIgnore is provided", () => {
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
const input = {
createdAt: "2024-03-20T15:30:00",
contactAttributes: {
createdAt: "2024-03-20T16:30:00",
email: "test@example.com",
},
};
const result = convertDatesInObject(input, keysToIgnore);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.contactAttributes.createdAt).toBe("2024-03-20T16:30:00");
expect(result.contactAttributes.email).toBe("test@example.com");
});
test("should not convert dates in variables when keysToIgnore is provided", () => {
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
const input = {
updatedAt: "2024-03-20T15:30:00",
variables: {
createdAt: "2024-03-20T16:30:00",
userId: "123",
},
};
const result = convertDatesInObject(input, keysToIgnore);
expect(result.updatedAt).toBeInstanceOf(Date);
expect(result.variables.createdAt).toBe("2024-03-20T16:30:00");
expect(result.variables.userId).toBe("123");
});
test("should not convert dates in data or meta when keysToIgnore is provided", () => {
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
const input = {
createdAt: "2024-03-20T15:30:00",
data: {
createdAt: "2024-03-20T16:30:00",
},
meta: {
updatedAt: "2024-03-20T17:30:00",
},
};
const result = convertDatesInObject(input, keysToIgnore);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.data.createdAt).toBe("2024-03-20T16:30:00");
expect(result.meta.updatedAt).toBe("2024-03-20T17:30:00");
});
test("should recurse into all keys when keysToIgnore is not provided", () => {
const input = {
createdAt: "2024-03-20T15:30:00",
contactAttributes: {
createdAt: "2024-03-20T16:30:00",
},
};
const result = convertDatesInObject(input);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.contactAttributes.createdAt).toBeInstanceOf(Date);
});
});
});

View File

@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string | null) => {
@@ -87,30 +87,28 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return de;
case "en-US":
return enUS;
case "es-ES":
return es;
case "fr-FR":
return fr;
case "hu-HU":
return hu;
case "ja-JP":
return ja;
case "nl-NL":
return nl;
case "pt-BR":
return ptBR;
case "fr-FR":
return fr;
case "nl-NL":
return nl;
case "sv-SE":
return sv;
case "zh-Hant-TW":
return zhTW;
case "pt-PT":
return pt;
case "ro-RO":
return ro;
case "ru-RU":
return ru;
case "sv-SE":
return sv;
case "ja-JP":
return ja;
case "zh-Hans-CN":
return zhCN;
case "zh-Hant-TW":
return zhTW;
case "es-ES":
return es;
case "ru-RU":
return ru;
}
};
@@ -151,20 +149,16 @@ export const getTodaysDateTimeFormatted = (seperator: string) => {
return [formattedDate, formattedTime].join(seperator);
};
export const convertDatesInObject = <T>(obj: T, keysToIgnore?: Set<string>): T => {
export const convertDatesInObject = <T>(obj: T): T => {
if (obj === null || typeof obj !== "object") {
return obj; // Return if obj is not an object
}
if (Array.isArray(obj)) {
// Handle arrays by mapping each element through the function
return obj.map((item) => convertDatesInObject(item, keysToIgnore)) as unknown as T;
return obj.map((item) => convertDatesInObject(item)) as unknown as T;
}
const newObj: Record<string, unknown> = {};
const newObj: any = {};
for (const key in obj) {
if (keysToIgnore?.has(key)) {
newObj[key] = obj[key];
continue;
}
if (
(key === "createdAt" || key === "updatedAt") &&
typeof obj[key] === "string" &&
@@ -172,10 +166,10 @@ export const convertDatesInObject = <T>(obj: T, keysToIgnore?: Set<string>): T =
) {
newObj[key] = new Date(obj[key] as unknown as string);
} else if (typeof obj[key] === "object" && obj[key] !== null) {
newObj[key] = convertDatesInObject(obj[key], keysToIgnore);
newObj[key] = convertDatesInObject(obj[key]);
} else {
newObj[key] = obj[key];
}
}
return newObj as T;
return newObj;
};

View File

@@ -1,261 +0,0 @@
import * as Sentry from "@sentry/nextjs";
import { getServerSession } from "next-auth";
import { DEFAULT_SERVER_ERROR_MESSAGE } from "next-safe-action";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
AuthenticationError,
AuthorizationError,
EXPECTED_ERROR_NAMES,
InvalidInputError,
OperationNotAllowedError,
ResourceNotFoundError,
TooManyRequestsError,
UnknownError,
ValidationError,
isExpectedError,
} from "@formbricks/types/errors";
// Mock Sentry
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
// Mock logger — use plain functions for chained calls so vi.resetAllMocks() doesn't break them
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: () => ({ error: vi.fn() }),
warn: vi.fn(),
},
}));
// Mock next-auth
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
// Mock authOptions
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
// Mock user service
vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
// Mock client IP
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: vi.fn(),
}));
// Mock constants
vi.mock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
}));
// Mock audit log types
vi.mock("@/modules/ee/audit-logs/types/audit-log", () => ({
UNKNOWN_DATA: "unknown",
}));
// ── shared helper tests (pure logic, no action client needed) ──────────
describe("isExpectedError (shared helper)", () => {
test("EXPECTED_ERROR_NAMES contains exactly the right error names", () => {
const expected = [
"ResourceNotFoundError",
"AuthorizationError",
"InvalidInputError",
"ValidationError",
"AuthenticationError",
"OperationNotAllowedError",
"TooManyRequestsError",
];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
for (const name of expected) {
expect(EXPECTED_ERROR_NAMES.has(name)).toBe(true);
}
});
test.each([
{ ErrorClass: AuthorizationError, args: ["Not authorized"] },
{ ErrorClass: AuthenticationError, args: ["Not authenticated"] },
{ ErrorClass: TooManyRequestsError, args: ["Rate limit exceeded"] },
{ ErrorClass: ResourceNotFoundError, args: ["Survey", "123"] },
{ ErrorClass: InvalidInputError, args: ["Invalid input"] },
{ ErrorClass: ValidationError, args: ["Invalid data"] },
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
const error = new (ErrorClass as any)(...args);
expect(isExpectedError(error)).toBe(true);
});
test("returns true for serialised errors that only have a matching name", () => {
const serialisedError = new Error("Auth failed");
serialisedError.name = "AuthorizationError";
expect(isExpectedError(serialisedError)).toBe(true);
});
test.each([
{ error: new Error("Something broke"), label: "Error" },
{ error: new TypeError("Cannot read properties"), label: "TypeError" },
{ error: new RangeError("Maximum call stack"), label: "RangeError" },
{ error: new UnknownError("Unknown"), label: "UnknownError" },
])("returns false for $label", ({ error }) => {
expect(isExpectedError(error)).toBe(false);
});
});
// ── integration tests against the real actionClient / authenticatedActionClient ──
describe("actionClient handleServerError", () => {
// Lazily import so mocks are in place first
let actionClient: (typeof import("./index"))["actionClient"];
beforeEach(async () => {
vi.clearAllMocks();
const mod = await import("./index");
actionClient = mod.actionClient;
});
afterEach(() => {
vi.clearAllMocks();
});
// Helper: create and execute an action that throws the given error
const executeThrowingAction = async (error: Error) => {
const action = actionClient.action(async () => {
throw error;
});
return action();
};
describe("expected errors should NOT be reported to Sentry", () => {
test("AuthorizationError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new AuthorizationError("Not authorized"));
expect(result?.serverError).toBe("Not authorized");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("AuthenticationError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new AuthenticationError("Not authenticated"));
expect(result?.serverError).toBe("Not authenticated");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("TooManyRequestsError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new TooManyRequestsError("Rate limit exceeded"));
expect(result?.serverError).toBe("Rate limit exceeded");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("ResourceNotFoundError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new ResourceNotFoundError("Survey", "123"));
expect(result?.serverError).toContain("Survey");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("InvalidInputError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new InvalidInputError("Invalid input"));
expect(result?.serverError).toBe("Invalid input");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("ValidationError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new ValidationError("Invalid data"));
expect(result?.serverError).toBe("Invalid data");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("OperationNotAllowedError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new OperationNotAllowedError("Not allowed"));
expect(result?.serverError).toBe("Not allowed");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
describe("unexpected errors SHOULD be reported to Sentry", () => {
test("generic Error is sent to Sentry and returns default message", async () => {
const error = new Error("Something broke");
const result = await executeThrowingAction(error);
expect(result?.serverError).toBe(DEFAULT_SERVER_ERROR_MESSAGE);
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({ extra: expect.any(Object) })
);
});
test("TypeError is sent to Sentry and returns default message", async () => {
const error = new TypeError("Cannot read properties of undefined");
const result = await executeThrowingAction(error);
expect(result?.serverError).toBe(DEFAULT_SERVER_ERROR_MESSAGE);
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({ extra: expect.any(Object) })
);
});
test("UnknownError is sent to Sentry (not an expected business-logic error)", async () => {
const error = new UnknownError("Unknown error");
const result = await executeThrowingAction(error);
expect(result?.serverError).toBe(DEFAULT_SERVER_ERROR_MESSAGE);
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({ extra: expect.any(Object) })
);
});
});
});
describe("authenticatedActionClient", () => {
let authenticatedActionClient: (typeof import("./index"))["authenticatedActionClient"];
let getUser: (typeof import("@/lib/user/service"))["getUser"];
beforeEach(async () => {
vi.clearAllMocks();
const mod = await import("./index");
authenticatedActionClient = mod.authenticatedActionClient;
const userService = await import("@/lib/user/service");
getUser = userService.getUser;
});
afterEach(() => {
vi.clearAllMocks();
});
test("throws AuthenticationError when there is no session", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const action = authenticatedActionClient.action(async () => "ok");
const result = await action();
// handleServerError catches AuthenticationError and returns its message
expect(result?.serverError).toBe("Not authenticated");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("throws AuthorizationError when user is not found", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } });
vi.mocked(getUser).mockResolvedValue(null as any);
const action = authenticatedActionClient.action(async () => "ok");
const result = await action();
expect(result?.serverError).toBe("User not found");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("executes action successfully when session and user exist", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } });
vi.mocked(getUser).mockResolvedValue({ id: "user-1", name: "Test" } as any);
const action = authenticatedActionClient.action(async () => "success");
const result = await action();
expect(result?.data).toBe("success");
expect(result?.serverError).toBeUndefined();
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});

View File

@@ -3,7 +3,15 @@ import { getServerSession } from "next-auth";
import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@formbricks/logger";
import { AuthenticationError, AuthorizationError, isExpectedError } from "@formbricks/types/errors";
import {
AuthenticationError,
AuthorizationError,
InvalidInputError,
OperationNotAllowedError,
ResourceNotFoundError,
TooManyRequestsError,
UnknownError,
} from "@formbricks/types/errors";
import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
@@ -14,18 +22,24 @@ import { ActionClientCtx } from "./types/context";
export const actionClient = createSafeActionClient({
handleServerError(e, utils) {
const eventId = (utils.ctx as Record<string, any>)?.auditLoggingCtx?.eventId ?? undefined; // keep explicit fallback
if (isExpectedError(e)) {
return e.message;
}
// Only capture unexpected errors to Sentry
Sentry.captureException(e, {
extra: {
eventId,
},
});
if (
e instanceof ResourceNotFoundError ||
e instanceof AuthorizationError ||
e instanceof InvalidInputError ||
e instanceof UnknownError ||
e instanceof AuthenticationError ||
e instanceof OperationNotAllowedError ||
e instanceof TooManyRequestsError
) {
return e.message;
}
// eslint-disable-next-line no-console -- This error needs to be logged for debugging server-side errors
logger.withContext({ eventId }).error(e, "SERVER ERROR");
return DEFAULT_SERVER_ERROR_MESSAGE;

View File

@@ -11,16 +11,3 @@ export const isSafeIdentifier = (value: string): boolean => {
// Can only contain lowercase letters, numbers, and underscores
return /^[a-z0-9_]+$/.test(value);
};
/**
* Converts a snake_case string to Title Case for display as a label.
* Example: "job_description" -> "Job Description"
* "api_key" -> "Api Key"
* "signup_date" -> "Signup Date"
*/
export const formatSnakeCaseToTitleCase = (key: string): string => {
return key
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};

View File

@@ -12,18 +12,11 @@ export function validateInputs<T extends ValidationPair<any>[]>(
for (const [value, schema] of pairs) {
const inputValidation = schema.safeParse(value);
if (!inputValidation.success) {
const zodDetails = inputValidation.error.issues
.map((issue) => {
const path = issue?.path?.join(".") ?? "";
return `${path}${issue.message}`;
})
.join("; ");
logger.error(
inputValidation.error,
`Validation failed for ${JSON.stringify(value).substring(0, 100)} and ${JSON.stringify(schema)}`
);
throw new ValidationError(`Validation failed: ${zodDetails}`);
throw new ValidationError("Validation failed");
}
parsedData.push(inputValidation.data);
}

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { getLocale } from "@/lingodotdev/language";
import { getTranslate } from "./server";
@@ -11,10 +11,6 @@ vi.mock("@/lingodotdev/shared", () => ({
}));
describe("lingodotdev server", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should get translate", async () => {
vi.mocked(getLocale).mockResolvedValue("en-US");
const translate = await getTranslate();
@@ -26,16 +22,4 @@ describe("lingodotdev server", () => {
const translate = await getTranslate();
expect(translate).toBeDefined();
});
test("should use provided locale instead of calling getLocale", async () => {
const translate = await getTranslate("de-DE");
expect(getLocale).not.toHaveBeenCalled();
expect(translate).toBeDefined();
});
test("should call getLocale when locale is not provided", async () => {
vi.mocked(getLocale).mockResolvedValue("fr-FR");
await getTranslate();
expect(getLocale).toHaveBeenCalled();
});
});

View File

@@ -2,7 +2,6 @@ import { createInstance } from "i18next";
import ICU from "i18next-icu";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next/initReactI18next";
import { TUserLocale } from "@formbricks/types/user";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getLocale } from "@/lingodotdev/language";
@@ -22,9 +21,9 @@ const initI18next = async (lng: string) => {
return i18nInstance;
};
export async function getTranslate(locale?: TUserLocale) {
const resolvedLocale = locale ?? (await getLocale());
export async function getTranslate() {
const locale = await getLocale();
const i18nextInstance = await initI18next(resolvedLocale);
return i18nextInstance.getFixedT(resolvedLocale);
const i18nextInstance = await initI18next(locale);
return i18nextInstance.getFixedT(locale);
}

View File

@@ -188,7 +188,6 @@
"customer_success": "Kundenerfolg",
"dark_overlay": "Dunkle Überlagerung",
"date": "Datum",
"days": "Tage",
"default": "Standard",
"delete": "Löschen",
"description": "Beschreibung",
@@ -244,6 +243,7 @@
"imprint": "Impressum",
"in_progress": "Im Gange",
"inactive_surveys": "Inaktive Umfragen",
"input_type": "Eingabetyp",
"integration": "Integration",
"integrations": "Integrationen",
"invalid_date": "Ungültiges Datum",
@@ -255,7 +255,6 @@
"label": "Bezeichnung",
"language": "Sprache",
"learn_more": "Mehr erfahren",
"license_expired": "License Expired",
"light_overlay": "Helle Überlagerung",
"limits_reached": "Limits erreicht",
"link": "Link",
@@ -268,15 +267,16 @@
"look_and_feel": "Darstellung",
"manage": "Verwalten",
"marketing": "Marketing",
"maximum": "Maximal",
"member": "Mitglied",
"members": "Mitglieder",
"members_and_teams": "Mitglieder & Teams",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
"mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
"months": "Monate",
"move_down": "Nach unten bewegen",
"move_up": "Nach oben bewegen",
"multiple_languages": "Mehrsprachigkeit",
@@ -287,7 +287,6 @@
"no_background_image_found": "Kein Hintergrundbild gefunden.",
"no_code": "No Code",
"no_files_uploaded": "Keine Dateien hochgeladen",
"no_overlay": "Kein Overlay",
"no_quotas_found": "Keine Kontingente gefunden",
"no_result_found": "Kein Ergebnis gefunden",
"no_results": "Keine Ergebnisse",
@@ -314,7 +313,6 @@
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
"other": "Andere",
"others": "Andere",
"overlay_color": "Overlay-Farbe",
"overview": "Überblick",
"password": "Passwort",
"paused": "Pausiert",
@@ -328,7 +326,7 @@
"placeholder": "Platzhalter",
"please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus",
"please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus",
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
"please_upgrade_your_plan": "Bitte upgrade deinen Plan.",
"preview": "Vorschau",
"preview_survey": "Umfragevorschau",
"privacy": "Datenschutz",
@@ -354,7 +352,6 @@
"request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen",
"response": "Antwort",
"response_id": "Antwort-ID",
"responses": "Antworten",
"restart": "Neustart",
"role": "Rolle",
@@ -395,7 +392,6 @@
"status": "Status",
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
"string": "Text",
"styling": "Styling",
"submit": "Abschicken",
"summary": "Zusammenfassung",
@@ -451,7 +447,6 @@
"website_and_app_connection": "Website & App Verbindung",
"website_app_survey": "Website- & App-Umfrage",
"website_survey": "Website-Umfrage",
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"workspace_configuration": "Projektkonfiguration",
"workspace_created_successfully": "Projekt erfolgreich erstellt",
@@ -462,15 +457,13 @@
"workspace_not_found": "Projekt nicht gefunden",
"workspace_permission_not_found": "Projektberechtigung nicht gefunden",
"workspaces": "Projekte",
"years": "Jahre",
"you": "Du",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
"you_are_not_authorized_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion durchzuführen.",
"you_have_reached_your_limit_of_workspace_limit": "Sie haben Ihr Limit von {projectLimit} Workspaces erreicht.",
"you_have_reached_your_monthly_miu_limit_of": "Du hast dein monatliches MIU-Limit erreicht",
"you_have_reached_your_monthly_response_limit_of": "Du hast dein monatliches Antwortlimit erreicht",
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft."
},
"emails": {
"accept": "Annehmen",
@@ -634,12 +627,6 @@
"attribute_updated_successfully": "Attribut erfolgreich aktualisiert",
"attribute_value": "Wert",
"attribute_value_placeholder": "Attributwert",
"attributes_msg_attribute_limit_exceeded": "Es konnten {count} neue Attribute nicht erstellt werden, da dies das maximale Limit von {limit} Attributklassen überschreiten würde. Bestehende Attribute wurden erfolgreich aktualisiert.",
"attributes_msg_attribute_type_validation_error": "{error} (Attribut '{key}' hat dataType: {dataType})",
"attributes_msg_email_already_exists": "Die E-Mail existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"attributes_msg_email_or_userid_required": "Entweder E-Mail oder userId ist erforderlich. Die bestehenden Werte wurden beibehalten.",
"attributes_msg_new_attribute_created": "Neues Attribut '{key}' mit Typ '{dataType}' erstellt",
"attributes_msg_userid_already_exists": "Die userId existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
"contact_not_found": "Kein solcher Kontakt gefunden",
"contacts_table_refresh": "Kontakte aktualisieren",
@@ -648,11 +635,6 @@
"create_key": "Schlüssel erstellen",
"create_new_attribute": "Neues Attribut erstellen",
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
"custom_attributes": "Benutzerdefinierte Attribute",
"data_type": "Datentyp",
"data_type_cannot_be_changed": "Der Datentyp kann nach der Erstellung nicht mehr geändert werden",
"data_type_description": "Wähle aus, wie dieses Attribut gespeichert und gefiltert werden soll",
"date_value_required": "Ein Datumswert ist erforderlich. Verwende die Löschen-Schaltfläche, um dieses Attribut zu entfernen, wenn du kein Datum festlegen möchtest.",
"delete_attribute_confirmation": "{value, plural, one {Dadurch wird das ausgewählte Attribut gelöscht. Alle mit diesem Attribut verknüpften Kontaktdaten gehen verloren.} other {Dadurch werden die ausgewählten Attribute gelöscht. Alle mit diesen Attributen verknüpften Kontaktdaten gehen verloren.}}",
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn dieser Kontakt Antworten hat, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.} other {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesen Kontakten verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn diesen Kontakten Antworten haben, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.}}",
@@ -660,18 +642,13 @@
"edit_attribute_description": "Aktualisieren Sie die Bezeichnung und Beschreibung für dieses Attribut.",
"edit_attribute_values": "Attribute bearbeiten",
"edit_attribute_values_description": "Ändern Sie die Werte für bestimmte Attribute dieses Kontakts.",
"edit_attributes": "Attribute bearbeiten",
"edit_attributes_success": "Kontaktattribute erfolgreich aktualisiert",
"generate_personal_link": "Persönlichen Link generieren",
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu generieren.",
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
"invalid_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.",
"invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.",
"no_published_link_surveys_available": "Keine veröffentlichten Link-Umfragen verfügbar. Bitte veröffentliche zuerst eine Link-Umfrage.",
"no_published_surveys": "Keine veröffentlichten Umfragen",
"no_responses_found": "Keine Antworten gefunden",
"not_provided": "Nicht angegeben",
"number_value_required": "Zahlenwert ist erforderlich. Verwende die Löschen-Schaltfläche, um dieses Attribut zu entfernen.",
"personal_link_generated": "Persönlicher Link erfolgreich generiert",
"personal_link_generated_but_clipboard_failed": "Persönlicher Link wurde generiert, konnte aber nicht in die Zwischenablage kopiert werden: {url}",
"personal_survey_link": "Link zur persönlichen Umfrage",
@@ -680,22 +657,13 @@
"search_contact": "Kontakt suchen",
"select_a_survey": "Wähle eine Umfrage aus",
"select_attribute": "Attribut auswählen",
"select_attribute_key": "Attributschlüssel auswählen",
"system_attributes": "Systemattribute",
"unlock_contacts_description": "Verwalte Kontakte und sende gezielte Umfragen",
"unlock_contacts_title": "Kontakte mit einem höheren Plan freischalten",
"upload_contacts_error_attribute_type_mismatch": "Attribut \"{key}\" ist als \"{dataType}\" definiert, aber die CSV-Datei enthält ungültige Werte: {values}",
"upload_contacts_error_duplicate_mappings": "Doppelte Zuordnungen für folgende Attribute gefunden: {attributes}",
"upload_contacts_error_file_too_large": "Dateigröße überschreitet das maximale Limit von 800KB",
"upload_contacts_error_generic": "Beim Hochladen der Kontakte ist ein Fehler aufgetreten. Bitte versuche es später erneut.",
"upload_contacts_error_invalid_file_type": "Bitte lade eine CSV-Datei hoch",
"upload_contacts_error_no_valid_contacts": "Die hochgeladene CSV-Datei enthält keine gültigen Kontakte. Bitte schaue dir die Beispiel-CSV-Datei für das richtige Format an.",
"upload_contacts_modal_attribute_header": "Formbricks-Attribut",
"upload_contacts_modal_attributes_description": "Ordne die Spalten in deiner CSV den Attributen in Formbricks zu.",
"upload_contacts_modal_attributes_new": "Neues Attribut",
"upload_contacts_modal_attributes_search_or_add": "Attribut suchen oder hinzufügen",
"upload_contacts_modal_attributes_should_be_mapped_to": "sollte zugeordnet werden zu",
"upload_contacts_modal_attributes_title": "Attribute",
"upload_contacts_modal_csv_column_header": "CSV-Spalte",
"upload_contacts_modal_description": "Lade eine CSV hoch, um Kontakte mit Attributen schnell zu importieren",
"upload_contacts_modal_download_example_csv": "Beispiel-CSV herunterladen",
"upload_contacts_modal_duplicates_description": "Wie sollen wir vorgehen, wenn ein Kontakt bereits existiert?",
@@ -752,12 +720,7 @@
"link_google_sheet": "Tabelle verlinken",
"link_new_sheet": "Neues Blatt verknüpfen",
"no_integrations_yet": "Deine verknüpften Tabellen werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
"reconnect_button": "Erneut verbinden",
"reconnect_button_description": "Deine Google Sheets-Verbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
"spreadsheet_permission_error": "Du hast keine Berechtigung, auf diese Tabelle zuzugreifen. Bitte stelle sicher, dass die Tabelle mit deinem Google-Konto geteilt ist und du Schreibzugriff auf die Tabelle hast.",
"spreadsheet_url": "Tabellen-URL",
"token_expired_error": "Das Google Sheets-Aktualisierungstoken ist abgelaufen oder wurde widerrufen. Bitte verbinde die Integration erneut."
"spreadsheet_url": "Tabellen-URL"
},
"include_created_at": "Erstellungsdatum einbeziehen",
"include_hidden_fields": "Versteckte Felder (hidden fields) einbeziehen",
@@ -881,40 +844,6 @@
"no_attributes_yet": "Noch keine Attribute",
"no_filters_yet": "Es gibt noch keine Filter",
"no_segments_yet": "Du hast momentan keine gespeicherten Segmente.",
"operator_contains": "enthält",
"operator_does_not_contain": "enthält nicht",
"operator_ends_with": "endet mit",
"operator_is_after": "ist nach",
"operator_is_before": "ist vor",
"operator_is_between": "ist zwischen",
"operator_is_newer_than": "ist neuer als",
"operator_is_not_set": "ist nicht festgelegt",
"operator_is_older_than": "ist älter als",
"operator_is_same_day": "ist am selben Tag",
"operator_is_set": "ist festgelegt",
"operator_starts_with": "fängt an mit",
"operator_title_contains": "Enthält",
"operator_title_does_not_contain": "Enthält nicht",
"operator_title_ends_with": "Endet mit",
"operator_title_equals": "Gleich",
"operator_title_greater_equal": "Größer als oder gleich",
"operator_title_greater_than": "Größer als",
"operator_title_is_after": "Ist nach",
"operator_title_is_before": "Ist vor",
"operator_title_is_between": "Ist zwischen",
"operator_title_is_newer_than": "Ist neuer als",
"operator_title_is_not_set": "Ist nicht festgelegt",
"operator_title_is_older_than": "Ist älter als",
"operator_title_is_same_day": "Ist am selben Tag",
"operator_title_is_set": "Ist festgelegt",
"operator_title_less_equal": "Kleiner oder gleich",
"operator_title_less_than": "Kleiner als",
"operator_title_not_equals": "Ist nicht gleich",
"operator_title_starts_with": "Fängt an mit",
"operator_title_user_is_in": "Nutzer ist in",
"operator_title_user_is_not_in": "Nutzer ist nicht in",
"operator_user_is_in": "Nutzer ist in",
"operator_user_is_not_in": "Nutzer ist nicht in",
"person_and_attributes": "Person & Attribute",
"phone": "Handy",
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Bitte entferne das Segment aus diesen Umfragen, um es zu löschen.",
@@ -939,7 +868,6 @@
"user_targeting_is_currently_only_available_when": "Benutzerzielgruppen sind derzeit nur verfügbar, wenn",
"value_cannot_be_empty": "Wert darf nicht leer sein.",
"value_must_be_a_number": "Wert muss eine Zahl sein.",
"value_must_be_positive": "Wert muss eine positive Zahl sein.",
"view_filters": "Filter anzeigen",
"where": "Wo",
"with_the_formbricks_sdk": "mit dem Formbricks SDK"
@@ -1026,32 +954,19 @@
"enterprise_features": "Unternehmensfunktionen",
"get_an_enterprise_license_to_get_access_to_all_features": "Hol dir eine Enterprise-Lizenz, um Zugriff auf alle Funktionen zu erhalten.",
"keep_full_control_over_your_data_privacy_and_security": "Behalte die volle Kontrolle über deine Daten, Privatsphäre und Sicherheit.",
"license_invalid_description": "Der Lizenzschlüssel in deiner ENTERPRISE_LICENSE_KEY-Umgebungsvariable ist nicht gültig. Bitte überprüfe auf Tippfehler oder fordere einen neuen Schlüssel an.",
"license_status": "Lizenzstatus",
"license_status_active": "Aktiv",
"license_status_description": "Status deiner Enterprise-Lizenz.",
"license_status_expired": "Abgelaufen",
"license_status_invalid": "Ungültige Lizenz",
"license_status_unreachable": "Nicht erreichbar",
"license_unreachable_grace_period": "Der Lizenzserver ist nicht erreichbar. Deine Enterprise-Funktionen bleiben während einer 3-tägigen Kulanzfrist bis zum {gracePeriodEnd} aktiv.",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Ganz unkompliziert: Fordere eine kostenlose 30-Tage-Testlizenz an, um alle Funktionen zu testen, indem Du dieses Formular ausfüllst:",
"no_credit_card_no_sales_call_just_test_it": "Keine Kreditkarte. Kein Verkaufsgespräch. Einfach testen :)",
"on_request": "Auf Anfrage",
"organization_roles": "Organisationsrollen (Admin, Editor, Entwickler, etc.)",
"questions_please_reach_out_to": "Fragen? Bitte melde Dich bei",
"recheck_license": "Lizenz erneut prüfen",
"recheck_license_failed": "Lizenzprüfung fehlgeschlagen. Der Lizenzserver ist möglicherweise nicht erreichbar.",
"recheck_license_invalid": "Der Lizenzschlüssel ist ungültig. Bitte überprüfe deinen ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Lizenzprüfung erfolgreich",
"recheck_license_unreachable": "Lizenzserver ist nicht erreichbar. Bitte versuche es später erneut.",
"rechecking": "Wird erneut geprüft...",
"request_30_day_trial_license": "30-Tage-Testlizenz anfordern",
"saml_sso": "SAML-SSO",
"service_level_agreement": "Service-Level-Vereinbarung",
"soc2_hipaa_iso_27001_compliance_check": "SOC2-, HIPAA- und ISO 27001-Konformitätsprüfung",
"sso": "SSO (Google, Microsoft, OpenID Connect)",
"teams": "Teams & Zugriffskontrolle (Lesen, Lesen & Schreiben, Verwalten)",
"unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos."
"unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos.",
"your_enterprise_license_is_active_all_features_unlocked": "Deine Unternehmenslizenz ist aktiv. Alle Funktionen freigeschaltet."
},
"general": {
"bulk_invite_warning_description": "Bitte beachte, dass im Free-Plan alle Organisationsmitglieder automatisch die Rolle \"Owner\" zugewiesen bekommen, unabhängig von der im CSV-File angegebenen Rolle.",
@@ -1075,7 +990,7 @@
"from_your_organization": "von deiner Organisation",
"invitation_sent_once_more": "Einladung nochmal gesendet.",
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
"invite_expires_on": "Einladung läuft ab am {date}",
"invited_on": "Eingeladen am {date}",
"invites_failed": "Einladungen fehlgeschlagen",
"leave_organization": "Organisation verlassen",
"leave_organization_description": "Du wirst diese Organisation verlassen und den Zugriff auf alle Umfragen und Antworten verlieren. Du kannst nur wieder beitreten, wenn Du erneut eingeladen wirst.",
@@ -1188,6 +1103,8 @@
"please_fill_all_workspace_fields": "Bitte füllen Sie alle Felder aus, um einen neuen Workspace hinzuzufügen.",
"read": "Lesen",
"read_write": "Lesen & Schreiben",
"select_member": "Mitglied auswählen",
"select_workspace": "Workspace auswählen",
"team_admin": "Team-Admin",
"team_created_successfully": "Team erfolgreich erstellt.",
"team_deleted_successfully": "Team erfolgreich gelöscht.",
@@ -1237,6 +1154,7 @@
"add_fallback_placeholder": "Platzhalter hinzufügen, falls kein Wert zur Verfügung steht.",
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
"add_highlight_border": "Rahmen hinzufügen",
"add_highlight_border_description": "Füge deiner Umfragekarte einen äußeren Rahmen hinzu.",
"add_logic": "Logik hinzufügen",
"add_none_of_the_above": "Füge \"Keine der oben genannten Optionen\" hinzu",
"add_option": "Option hinzufügen",
@@ -1254,6 +1172,7 @@
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
"adjust_the_theme_in_the": "Passe das Thema an in den",
"all_other_answers_will_continue_to": "Alle anderen Antworten werden weiterhin",
"allow_file_type": "Dateityp begrenzen",
"allow_multi_select": "Mehrfachauswahl erlauben",
"allow_multiple_files": "Mehrere Dateien zulassen",
"allow_users_to_select_more_than_one_image": "Erlaube Nutzern, mehr als ein Bild auszuwählen",
@@ -1275,7 +1194,6 @@
"block_duplicated": "Block dupliziert.",
"bold": "Fett",
"brand_color": "Markenfarbe",
"brand_color_description": "Wird auf Buttons, Links und Hervorhebungen angewendet.",
"brightness": "Helligkeit",
"bulk_edit": "Massenbearbeitung",
"bulk_edit_description": "Bearbeiten Sie alle Optionen unten, eine pro Zeile. Leere Zeilen werden übersprungen und Duplikate entfernt.",
@@ -1293,9 +1211,7 @@
"capture_new_action": "Neue Aktion erfassen",
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
"card_background_color": "Hintergrundfarbe der Karte",
"card_background_color_description": "Füllt den Bereich der Umfragekarte.",
"card_border_color": "Farbe des Kartenrandes",
"card_border_color_description": "Umrandet die Umfragekarte.",
"card_styling": "Kartengestaltung",
"casual": "Lässig",
"caution_edit_duplicate": "Duplizieren & bearbeiten",
@@ -1306,14 +1222,24 @@
"caution_explanation_responses_are_safe": "Ältere und neuere Antworten vermischen sich, was zu irreführenden Datensummen führen kann.",
"caution_recommendation": "Dies kann im Umfrageübersicht zu Dateninkonsistenzen führen. Wir empfehlen stattdessen, die Umfrage zu duplizieren.",
"caution_text": "Änderungen werden zu Inkonsistenzen führen",
"centered_modal_overlay_color": "Zentrierte modale Überlagerungsfarbe",
"change_anyway": "Trotzdem ändern",
"change_background": "Hintergrund ändern",
"change_question_type": "Fragetyp ändern",
"change_survey_type": "Die Änderung des Umfragetypen kann vorhandenen Zugriff beeinträchtigen",
"change_the_background_color_of_the_card": "Hintergrundfarbe der Karte ändern.",
"change_the_background_color_of_the_input_fields": "Hintergrundfarbe der Eingabefelder ändern.",
"change_the_background_to_a_color_image_or_animation": "Hintergrund zu einer Farbe, einem Bild oder einer Animation ändern.",
"change_the_border_color_of_the_card": "Randfarbe der Karte ändern.",
"change_the_border_color_of_the_input_fields": "Randfarbe der Eingabefelder ändern.",
"change_the_border_radius_of_the_card_and_the_inputs": "Radius der Ränder der Karte und der Eingabefelder ändern.",
"change_the_brand_color_of_the_survey": "Markenfarbe der Umfrage ändern.",
"change_the_placement_of_this_survey": "Platzierung dieser Umfrage ändern.",
"change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.",
"changes_saved": "Änderungen gespeichert.",
"changing_survey_type_will_remove_existing_distribution_channels": "\"Das Ändern des Umfragetypen beeinflusst, wie er geteilt werden kann. Wenn Teilnehmer bereits Zugriffslinks für den aktuellen Typ haben, könnten sie das Zugriffsrecht nach dem Wechsel verlieren.\"",
"character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.",
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
"checkbox_label": "Checkbox-Beschriftung",
"choose_the_actions_which_trigger_the_survey": "Aktionen auswählen, die die Umfrage auslösen.",
"choose_the_first_question_on_your_block": "Wählen sie die erste frage in ihrem block",
@@ -1333,6 +1259,7 @@
"contact_fields": "Kontaktfelder",
"contains": "enthält",
"continue_to_settings": "Weiter zu den Einstellungen",
"control_which_file_types_can_be_uploaded": "Steuere, welche Dateitypen hochgeladen werden können.",
"convert_to_multiple_choice": "In Multiple-Choice umwandeln",
"convert_to_single_choice": "In Einzelauswahl umwandeln",
"country": "Land",
@@ -1345,13 +1272,11 @@
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"date_format": "Datumsformat",
"days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.",
"delete_anyways": "Trotzdem löschen",
"delete_block": "Block löschen",
"delete_choice": "Auswahl löschen",
"disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.",
"display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an",
"display_number_of_responses_for_survey": "Anzahl der Antworten für Umfrage anzeigen",
"display_type": "Anzeigetyp",
"divide": "Teilen /",
"does_not_contain": "Enthält nicht",
"does_not_end_with": "Endet nicht mit",
@@ -1359,7 +1284,6 @@
"does_not_include_all_of": "Enthält nicht alle von",
"does_not_include_one_of": "Enthält nicht eines von",
"does_not_start_with": "Fängt nicht an mit",
"dropdown": "Dropdown",
"duplicate_block": "Block duplizieren",
"duplicate_question": "Frage duplizieren",
"edit_link": "Bearbeitungslink",
@@ -1451,7 +1375,8 @@
"hide_progress_bar": "Fortschrittsbalken ausblenden",
"hide_question_settings": "Frageeinstellungen ausblenden",
"hostname": "Hostname",
"if_you_need_more_please": "Wenn Sie mehr benötigen, bitte",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
"if_you_need_more_please": "Wenn Du mehr brauchst, bitte",
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
"ignore_global_waiting_time": "Abkühlphase ignorieren",
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
@@ -1461,9 +1386,7 @@
"initial_value": "Anfangswert",
"inner_text": "Innerer Text",
"input_border_color": "Randfarbe des Eingabefelds",
"input_border_color_description": "Umrandet Texteingaben und Textbereiche.",
"input_color": "Farbe des Eingabefelds",
"input_color_description": "Füllt das Innere von Texteingaben.",
"insert_link": "Link einfügen",
"invalid_targeting": "Ungültiges Targeting: Bitte überprüfe deine Zielgruppenfilter",
"invalid_video_url_warning": "Bitte gib eine gültige YouTube-, Vimeo- oder Loom-URL ein. Andere Video-Plattformen werden derzeit nicht unterstützt.",
@@ -1490,10 +1413,10 @@
"key": "Schlüssel",
"last_name": "Nachname",
"let_people_upload_up_to_25_files_at_the_same_time": "Erlaube bis zu 25 Dateien gleichzeitig hochzuladen.",
"limit_the_maximum_file_size": "Begrenzen Sie die maximale Dateigröße für Uploads.",
"limit_upload_file_size_to": "Upload-Dateigröße begrenzen auf",
"limit_file_types": "Dateitypen einschränken",
"limit_the_maximum_file_size": "Maximale Dateigröße begrenzen",
"limit_upload_file_size_to": "Maximale Dateigröße für Uploads",
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
"list": "Liste",
"load_segment": "Segment laden",
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
"logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage",
@@ -1504,8 +1427,8 @@
"manage_languages": "Sprachen verwalten",
"matrix_all_fields": "Alle Felder",
"matrix_rows": "Zeilen",
"max_file_size": "Maximale Dateigröße",
"max_file_size_limit_is": "Die maximale Dateigrößenbeschränkung beträgt",
"max_file_size": "Max. Dateigröße",
"max_file_size_limit_is": "Max. Dateigröße ist",
"move_question_to_block": "Frage in Block verschieben",
"multiply": "Multiplizieren *",
"needed_for_self_hosted_cal_com_instance": "Benötigt für eine selbstgehostete Cal.com-Instanz",
@@ -1537,6 +1460,7 @@
"picture_idx": "Bild {idx}",
"pin_can_only_contain_numbers": "PIN darf nur Zahlen enthalten.",
"pin_must_be_a_four_digit_number": "Die PIN muss eine vierstellige Zahl sein.",
"please_enter_a_file_extension": "Bitte gib eine Dateierweiterung ein.",
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein (z. B. https://beispiel.de)",
"please_set_a_survey_trigger": "Bitte richte einen Umfrage-Trigger ein",
"please_specify": "Bitte angeben",
@@ -1547,12 +1471,12 @@
"protect_survey_with_pin_description": "Nur Benutzer, die die PIN haben, können auf die Umfrage zugreifen.",
"publish": "Veröffentlichen",
"question": "Frage",
"question_color": "Fragefarbe",
"question_deleted": "Frage gelöscht.",
"question_duplicated": "Frage dupliziert.",
"question_id_updated": "Frage-ID aktualisiert",
"question_used_in_logic_warning_text": "Elemente aus diesem Block werden in einer Logikregel verwendet. Möchten Sie ihn wirklich löschen?",
"question_used_in_logic_warning_title": "Logikinkonsistenz",
"question_used_in_quota": "Diese Frage wird in der “{quotaName}” Quote verwendet",
"question_used_in_logic": "Diese Frage wird in der Logik der Frage {questionIndex} verwendet.",
"question_used_in_quota": "Diese Frage wird in der \"{quotaName}\" Quote verwendet",
"question_used_in_recall": "Diese Frage wird in Frage {questionIndex} abgerufen.",
"question_used_in_recall_ending_card": "Diese Frage wird in der Abschlusskarte abgerufen.",
"quotas": {
@@ -1608,7 +1532,6 @@
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
"response_options": "Antwortoptionen",
"roundness": "Rundheit",
"roundness_description": "Steuert, wie abgerundet die Kartenecken sind.",
"row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
"rows": "Zeilen",
"save_and_close": "Speichern & Schließen",
@@ -1616,7 +1539,6 @@
"search_for_images": "Nach Bildern suchen",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Sekunden nach dem Auslösen wird die Umfrage geschlossen, wenn keine Antwort erfolgt.",
"seconds_before_showing_the_survey": "Sekunden, bevor die Umfrage angezeigt wird.",
"select_field": "Feld auswählen",
"select_or_type_value": "Auswählen oder Wert eingeben",
"select_ordering": "Anordnung auswählen",
"select_saved_action": "Gespeicherte Aktion auswählen",
@@ -1650,6 +1572,7 @@
"styling_set_to_theme_styles": "Styling auf Themenstile eingestellt",
"subheading": "Zwischenüberschrift",
"subtract": "Subtrahieren -",
"suggest_colors": "Farben vorschlagen",
"survey_completed_heading": "Umfrage abgeschlossen",
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
@@ -1663,6 +1586,8 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Einmal anzeigen, auch wenn sie nicht antworten.",
"then": "dann",
"this_action_will_remove_all_the_translations_from_this_survey": "Diese Aktion entfernt alle Übersetzungen aus dieser Umfrage.",
"this_extension_is_already_added": "Diese Erweiterung ist bereits hinzugefügt.",
"this_file_type_is_not_supported": "Dieser Dateityp wird nicht unterstützt.",
"three_points": "3 Punkte",
"times": "Zeiten",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg konsistent zu halten, kannst du",
@@ -1683,51 +1608,8 @@
"upper_label": "Oberes Label",
"url_filters": "URL-Filter",
"url_not_supported": "URL nicht unterstützt",
"validation": {
"add_validation_rule": "Validierungsregel hinzufügen",
"answer_all_rows": "Alle Zeilen beantworten",
"characters": "Zeichen",
"contains": "enthält",
"delete_validation_rule": "Validierungsregel löschen",
"does_not_contain": "enthält nicht",
"email": "Ist gültige E-Mail",
"end_date": "Enddatum",
"file_extension_is": "Dateierweiterung ist",
"file_extension_is_not": "Dateierweiterung ist nicht",
"is": "ist",
"is_between": "ist zwischen",
"is_earlier_than": "ist früher als",
"is_greater_than": "ist größer als",
"is_later_than": "ist später als",
"is_less_than": "ist weniger als",
"is_not": "ist nicht",
"is_not_between": "ist nicht zwischen",
"kb": "KB",
"max_length": "Höchstens",
"max_selections": "Höchstens",
"max_value": "Höchstens",
"mb": "MB",
"min_length": "Mindestens",
"min_selections": "Mindestens",
"min_value": "Mindestens",
"minimum_options_ranked": "Mindestanzahl bewerteter Optionen",
"minimum_rows_answered": "Mindestanzahl beantworteter Zeilen",
"options_selected": "Optionen ausgewählt",
"pattern": "Entspricht Regex-Muster",
"phone": "Ist gültige Telefonnummer",
"rank_all_options": "Alle Optionen bewerten",
"select_file_extensions": "Dateierweiterungen auswählen...",
"select_option": "Option auswählen",
"start_date": "Startdatum",
"url": "Ist gültige URL"
},
"validation_logic_and": "Alle sind wahr",
"validation_logic_or": "mindestens eine ist wahr",
"validation_rules": "Validierungsregeln",
"validation_rules_description": "Nur Antworten akzeptieren, die die folgenden Kriterien erfüllen",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable {variableName} wird in der {quotaName} Quote verwendet",
"variable_name_conflicts_with_hidden_field": "Der Variablenname steht im Konflikt mit einer vorhandenen Hidden-Field-ID.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
"variable_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
"variable_used_in_recall": "Variable \"{variable}\" wird in Frage {questionIndex} abgerufen.",
@@ -2133,71 +2015,9 @@
"look": {
"add_background_color": "Hintergrundfarbe hinzufügen",
"add_background_color_description": "Füge dem Logo-Container eine Hintergrundfarbe hinzu.",
"advanced_styling_field_border_radius": "Rahmenradius",
"advanced_styling_field_button_bg": "Button-Hintergrund",
"advanced_styling_field_button_bg_description": "Füllt den Weiter-/Absenden-Button.",
"advanced_styling_field_button_border_radius_description": "Rundet die Button-Ecken ab.",
"advanced_styling_field_button_font_size_description": "Skaliert den Text der Button-Beschriftung.",
"advanced_styling_field_button_font_weight_description": "Macht den Button-Text heller oder fetter.",
"advanced_styling_field_button_height_description": "Steuert die Button-Höhe.",
"advanced_styling_field_button_padding_x_description": "Fügt links und rechts Abstand hinzu.",
"advanced_styling_field_button_padding_y_description": "Fügt oben und unten Abstand hinzu.",
"advanced_styling_field_button_text": "Button-Text",
"advanced_styling_field_button_text_description": "Färbt die Beschriftung innerhalb von Buttons.",
"advanced_styling_field_description_color": "Beschreibungsfarbe",
"advanced_styling_field_description_color_description": "Färbt den Text unterhalb jeder Überschrift.",
"advanced_styling_field_description_size": "Schriftgröße der Beschreibung",
"advanced_styling_field_description_size_description": "Skaliert den Beschreibungstext.",
"advanced_styling_field_description_weight": "Schriftstärke der Beschreibung",
"advanced_styling_field_description_weight_description": "Macht den Beschreibungstext heller oder fetter.",
"advanced_styling_field_font_size": "Schriftgröße",
"advanced_styling_field_font_weight": "Schriftstärke",
"advanced_styling_field_headline_color": "Überschriftsfarbe",
"advanced_styling_field_headline_color_description": "Färbt den Hauptfragetext.",
"advanced_styling_field_headline_size": "Schriftgröße der Überschrift",
"advanced_styling_field_headline_size_description": "Skaliert den Überschriftentext.",
"advanced_styling_field_headline_weight": "Schriftstärke der Überschrift",
"advanced_styling_field_headline_weight_description": "Macht den Überschriftentext heller oder fetter.",
"advanced_styling_field_height": "Mindesthöhe",
"advanced_styling_field_indicator_bg": "Indikator-Hintergrund",
"advanced_styling_field_indicator_bg_description": "Färbt den gefüllten Teil des Balkens.",
"advanced_styling_field_input_border_radius_description": "Rundet die Eingabeecken ab.",
"advanced_styling_field_input_font_size_description": "Skaliert den eingegebenen Text in Eingabefeldern.",
"advanced_styling_field_input_height_description": "Legt die Mindesthöhe des Eingabefelds fest.",
"advanced_styling_field_input_padding_x_description": "Fügt links und rechts Abstand hinzu.",
"advanced_styling_field_input_padding_y_description": "Fügt oben und unten Abstand hinzu.",
"advanced_styling_field_input_placeholder_opacity_description": "Blendet den Platzhaltertext aus.",
"advanced_styling_field_input_shadow_description": "Fügt einen Schlagschatten um Eingabefelder hinzu.",
"advanced_styling_field_input_text": "Eingabetext",
"advanced_styling_field_input_text_description": "Färbt den eingegebenen Text in Eingabefeldern.",
"advanced_styling_field_option_bg": "Hintergrund",
"advanced_styling_field_option_bg_description": "Füllt die Optionselemente.",
"advanced_styling_field_option_border_radius_description": "Rundet die Ecken der Optionen ab.",
"advanced_styling_field_option_font_size_description": "Skaliert den Text der Optionsbeschriftung.",
"advanced_styling_field_option_label": "Label-Farbe",
"advanced_styling_field_option_label_description": "Färbt den Text der Optionsbeschriftung.",
"advanced_styling_field_option_padding_x_description": "Fügt links und rechts Abstand hinzu.",
"advanced_styling_field_option_padding_y_description": "Fügt oben und unten Abstand hinzu.",
"advanced_styling_field_padding_x": "Innenabstand X",
"advanced_styling_field_padding_y": "Innenabstand Y",
"advanced_styling_field_placeholder_opacity": "Platzhalter-Deckkraft",
"advanced_styling_field_shadow": "Schatten",
"advanced_styling_field_track_bg": "Track-Hintergrund",
"advanced_styling_field_track_bg_description": "Färbt den nicht ausgefüllten Teil des Balkens.",
"advanced_styling_field_track_height": "Track-Höhe",
"advanced_styling_field_track_height_description": "Steuert die Dicke des Fortschrittsbalkens.",
"advanced_styling_field_upper_label_color": "Farbe des oberen Labels",
"advanced_styling_field_upper_label_color_description": "Färbt die kleine Beschriftung über Eingabefeldern.",
"advanced_styling_field_upper_label_size": "Schriftgröße des oberen Labels",
"advanced_styling_field_upper_label_size_description": "Skaliert die kleine Beschriftung über Eingabefeldern.",
"advanced_styling_field_upper_label_weight": "Schriftstärke des oberen Labels",
"advanced_styling_field_upper_label_weight_description": "Macht die Beschriftung leichter oder fetter.",
"advanced_styling_section_buttons": "Buttons",
"advanced_styling_section_headlines": "Überschriften & Beschreibungen",
"advanced_styling_section_inputs": "Eingabefelder",
"advanced_styling_section_options": "Optionen (Radio/Checkbox)",
"app_survey_placement": "Platzierung der App-Umfrage",
"app_survey_placement_settings_description": "Ändere, wo Umfragen in deiner Web-App oder Website angezeigt werden.",
"centered_modal_overlay_color": "Zentrierte modale Überlagerungsfarbe",
"email_customization": "E-Mail-Anpassung",
"email_customization_description": "Ändere das Aussehen und die Gestaltung von E-Mails, die Formbricks in deinem Namen versendet.",
"enable_custom_styling": "Benutzerdefiniertes Styling aktivieren",
@@ -2208,9 +2028,6 @@
"formbricks_branding_hidden": "Formbricks-Branding ist ausgeblendet.",
"formbricks_branding_settings_description": "Wir freuen uns über deine Unterstützung, haben aber Verständnis, wenn du es ausschaltest.",
"formbricks_branding_shown": "Formbricks-Branding wird angezeigt.",
"generate_theme_btn": "Generieren",
"generate_theme_confirmation": "Möchtest du ein passendes Farbschema basierend auf deiner Markenfarbe generieren? Dies überschreibt deine aktuellen Farbeinstellungen.",
"generate_theme_header": "Farbschema generieren?",
"logo_removed_successfully": "Logo erfolgreich entfernt",
"logo_settings_description": "Lade dein Firmenlogo hoch, um Umfragen und Link-Vorschauen zu branden.",
"logo_updated_successfully": "Logo erfolgreich aktualisiert",
@@ -2225,8 +2042,6 @@
"show_formbricks_branding_in": "Formbricks-Branding in {type}-Umfragen anzeigen",
"show_powered_by_formbricks": "\"Powered by Formbricks\"-Signatur anzeigen",
"styling_updated_successfully": "Styling erfolgreich aktualisiert",
"suggest_colors": "Farben vorschlagen",
"suggested_colors_applied_please_save": "Vorgeschlagene Farben erfolgreich generiert. Drücke \"Speichern\", um die Änderungen zu übernehmen.",
"theme": "Theme",
"theme_settings_description": "Erstelle ein Style-Theme für alle Umfragen. Du kannst für jede Umfrage individuelles Styling aktivieren."
},
@@ -2990,7 +2805,6 @@
"preview_survey_question_2_choice_1_label": "Ja, halte mich auf dem Laufenden.",
"preview_survey_question_2_choice_2_label": "Nein, danke!",
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
"preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.",
"preview_survey_welcome_card_headline": "Willkommen!",
"prioritize_features_description": "Identifiziere die Funktionen, die deine Nutzer am meisten und am wenigsten brauchen.",
"prioritize_features_name": "Funktionen priorisieren",

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