Compare commits
1 Commits
feat/dashb
...
poc-featur
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53e7d18ee6 |
28
.env.example
@@ -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=
|
||||
@@ -229,24 +224,5 @@ REDIS_URL=redis://localhost:6379
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
|
||||
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
|
||||
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
|
||||
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
|
||||
# CUBEJS_API_SECRET=
|
||||
# URL where the Cube.js instance is running
|
||||
# CUBEJS_API_URL=http://localhost:4000
|
||||
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
|
||||
# CUBEJS_API_TOKEN=
|
||||
#
|
||||
# Cube connects to the Hub DB. When using docker-compose.dev.yml with the hub network,
|
||||
# use the container name and internal port. Hub credentials: formbricks/formbricks_dev, db: hub
|
||||
# CUBEJS_DB_HOST=formbricks_hub_postgres
|
||||
# CUBEJS_DB_PORT=5432
|
||||
# CUBEJS_DB_NAME=hub
|
||||
# CUBEJS_DB_USER=formbricks
|
||||
# CUBEJS_DB_PASS=formbricks_dev
|
||||
#
|
||||
# Alternative (when not on same Docker network): host.docker.internal and port 5433
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGODOTDEV_API_KEY=your_api_key_here
|
||||
6
.github/workflows/release-helm-chart.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
4
.github/workflows/sonarqube.yml
vendored
@@ -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 }}
|
||||
|
||||
57
.github/workflows/translation-check.yml
vendored
@@ -6,9 +6,19 @@ permissions:
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "apps/web/**/*.ts"
|
||||
- "apps/web/**/*.tsx"
|
||||
- "apps/web/locales/**/*.json"
|
||||
- "scan-translations.ts"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "apps/web/**/*.ts"
|
||||
- "apps/web/**/*.tsx"
|
||||
- "apps/web/locales/**/*.json"
|
||||
- "scan-translations.ts"
|
||||
|
||||
jobs:
|
||||
validate-translations:
|
||||
@@ -22,39 +32,32 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Check for relevant changes
|
||||
id: changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
filters: |
|
||||
translations:
|
||||
- 'apps/web/**/*.ts'
|
||||
- 'apps/web/**/*.tsx'
|
||||
- 'apps/web/locales/**/*.json'
|
||||
- 'packages/surveys/src/**/*.{ts,tsx}'
|
||||
- 'packages/surveys/locales/**/*.json'
|
||||
- 'packages/email/**/*.{ts,tsx}'
|
||||
node-version: 18
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
version: 9.15.9
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Validate translation keys
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
run: pnpm run scan-translations
|
||||
run: |
|
||||
echo ""
|
||||
echo "🔍 Validating translation keys..."
|
||||
echo ""
|
||||
pnpm run scan-translations
|
||||
|
||||
- name: Skip (no translation-related changes)
|
||||
if: steps.changes.outputs.translations != 'true'
|
||||
run: echo "No translation-related files changed — skipping validation."
|
||||
- name: Summary
|
||||
if: success()
|
||||
run: |
|
||||
echo ""
|
||||
echo "✅ Translation validation completed successfully!"
|
||||
echo ""
|
||||
|
||||
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -1 +1,40 @@
|
||||
pnpm lint-staged
|
||||
# Load environment variables from .env files
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
. .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
pnpm lint-staged
|
||||
|
||||
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
|
||||
if [ -n "$LINGODOTDEV_API_KEY" ]; then
|
||||
echo ""
|
||||
echo "🌍 Running Lingo.dev translation workflow..."
|
||||
echo ""
|
||||
|
||||
# Run translation generation and validation
|
||||
if pnpm run i18n; then
|
||||
echo ""
|
||||
echo "✅ Translation validation passed"
|
||||
echo ""
|
||||
# Add updated locale files to git
|
||||
git add apps/web/locales/*.json
|
||||
else
|
||||
echo ""
|
||||
echo "❌ Translation validation failed!"
|
||||
echo ""
|
||||
echo "Please fix the translation issues above before committing:"
|
||||
echo " • Add missing translation keys to your locale files"
|
||||
echo " • Remove unused translation keys"
|
||||
echo ""
|
||||
echo "Or run 'pnpm i18n' to see the detailed report"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
|
||||
echo " (This is expected for community contributors)"
|
||||
echo ""
|
||||
fi
|
||||
417
ENTERPRISE_FEATURE_ANALYSIS.md
Normal 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
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -25,7 +25,7 @@ const mockProject: TProject = {
|
||||
},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
languages: [],
|
||||
logo: null,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
|
||||
|
||||
const ChartsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const { environmentId } = await props.params;
|
||||
return <ChartsListPage environmentId={environmentId} />;
|
||||
};
|
||||
|
||||
export default ChartsPage;
|
||||
@@ -1,7 +0,0 @@
|
||||
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
|
||||
|
||||
const Page = (props: { params: Promise<{ environmentId: string; dashboardId: string }> }) => {
|
||||
return <DashboardDetailPage params={props.params} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
|
||||
|
||||
const DashboardsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const { environmentId } = await props.params;
|
||||
return <DashboardsListPage environmentId={environmentId} />;
|
||||
};
|
||||
|
||||
export default DashboardsPage;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const AnalysisPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const { environmentId } = await props.params;
|
||||
return redirect(`/environments/${environmentId}/analysis/dashboards`);
|
||||
};
|
||||
|
||||
export default AnalysisPage;
|
||||
@@ -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">
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
ChartBar,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
LogOutIcon,
|
||||
@@ -110,17 +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"),
|
||||
},
|
||||
{
|
||||
name: t("common.analysis"),
|
||||
href: `/environments/${environment.id}/analysis`,
|
||||
icon: ChartBar,
|
||||
isActive: pathname?.includes("/analysis"),
|
||||
isHidden: false,
|
||||
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
@@ -196,7 +185,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
|
||||
@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
|
||||
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort organizations by name
|
||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
setOrganizations(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
|
||||
@@ -82,7 +82,7 @@ export const ProjectBreadcrumb = ({
|
||||
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort projects by name
|
||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
setProjects(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -107,31 +106,3 @@ export const getResponseCountAction = authenticatedActionClient
|
||||
|
||||
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
const ZGetDisplaysWithContactAction = z.object({
|
||||
surveyId: ZId,
|
||||
limit: z.number().int().min(1).max(100),
|
||||
offset: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const getDisplaysWithContactAction = authenticatedActionClient
|
||||
.schema(ZGetDisplaysWithContactAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -58,7 +58,6 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||
ctx.user.email,
|
||||
emailHtml,
|
||||
survey.environmentId,
|
||||
ctx.user.locale,
|
||||
organizationLogoUrl || ""
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircleIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TDisplayWithContact } from "@formbricks/types/displays";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface SummaryImpressionsProps {
|
||||
displays: TDisplayWithContact[];
|
||||
isLoading: boolean;
|
||||
hasMore: boolean;
|
||||
displaysError: string | null;
|
||||
environmentId: string;
|
||||
locale: TUserLocale;
|
||||
onLoadMore: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
|
||||
if (!display.contact) return "";
|
||||
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
|
||||
};
|
||||
|
||||
export const SummaryImpressions = ({
|
||||
displays,
|
||||
isLoading,
|
||||
hasMore,
|
||||
displaysError,
|
||||
environmentId,
|
||||
locale,
|
||||
onLoadMore,
|
||||
onRetry,
|
||||
}: SummaryImpressionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderContent = () => {
|
||||
if (displaysError) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<AlertCircleIcon className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">{displaysError}</p>
|
||||
<Button onClick={onRetry} variant="secondary" size="sm">
|
||||
{t("common.try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (displays.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.no_identified_impressions")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid min-h-10 grid-cols-4 items-center border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
|
||||
<div className="col-span-2 px-4 md:px-6">{t("common.user")}</div>
|
||||
<div className="col-span-2 px-4 md:px-6">{t("environments.contacts.survey_viewed_at")}</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[62vh] overflow-y-auto">
|
||||
{displays.map((display) => (
|
||||
<div
|
||||
key={display.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-xs text-slate-800 last:border-transparent md:text-sm">
|
||||
<div className="col-span-2 pl-4 md:pl-6">
|
||||
{display.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture break-all text-slate-600 hover:underline"
|
||||
href={`/environments/${environmentId}/contacts/${display.contact.id}`}>
|
||||
{getDisplayContactIdentifier(display)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="break-all text-slate-600">{t("common.anonymous")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 px-4 text-slate-500 md:px-6">
|
||||
{timeSince(display.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center border-t border-slate-100 py-4">
|
||||
<Button onClick={onLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<InfoIcon className="h-4 w-4 shrink-0" />
|
||||
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,8 +10,8 @@ interface SummaryMetadataProps {
|
||||
surveySummary: TSurveySummary["meta"];
|
||||
quotasCount: number;
|
||||
isLoading: boolean;
|
||||
tab: "dropOffs" | "quotas" | "impressions" | undefined;
|
||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>;
|
||||
tab: "dropOffs" | "quotas" | undefined;
|
||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
||||
isQuotasAllowed: boolean;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
|
||||
const { t } = useTranslation();
|
||||
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
|
||||
|
||||
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
|
||||
const handleTabChange = (val: "dropOffs" | "quotas") => {
|
||||
const change = tab === val ? undefined : val;
|
||||
setTab(change);
|
||||
};
|
||||
@@ -65,16 +65,12 @@ export const SummaryMetadata = ({
|
||||
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
|
||||
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
|
||||
)}>
|
||||
<InteractiveCard
|
||||
key="impressions"
|
||||
tab="impressions"
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.impressions")}
|
||||
percentage={null}
|
||||
value={displayCount === 0 ? <span>-</span> : displayCount}
|
||||
tooltipText={t("environments.surveys.summary.impressions_tooltip")}
|
||||
isLoading={isLoading}
|
||||
onClick={() => handleTabChange("impressions")}
|
||||
isActive={tab === "impressions"}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.starts")}
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TDisplayWithContact } from "@formbricks/types/displays";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import {
|
||||
getDisplaysWithContactAction,
|
||||
getSurveySummaryAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
|
||||
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||
import { SummaryImpressions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryImpressions";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
|
||||
import { SummaryList } from "./SummaryList";
|
||||
import { SummaryMetadata } from "./SummaryMetadata";
|
||||
|
||||
const DISPLAYS_PER_PAGE = 15;
|
||||
|
||||
const defaultSurveySummary: TSurveySummary = {
|
||||
meta: {
|
||||
completedPercentage: 0,
|
||||
@@ -61,76 +51,17 @@ export const SummaryPage = ({
|
||||
initialSurveySummary,
|
||||
isQuotasAllowed,
|
||||
}: SummaryPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
||||
initialSurveySummary || defaultSurveySummary
|
||||
);
|
||||
|
||||
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
|
||||
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
||||
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
|
||||
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
|
||||
const [hasMoreDisplays, setHasMoreDisplays] = useState(true);
|
||||
const [displaysError, setDisplaysError] = useState<string | null>(null);
|
||||
const displaysFetchedRef = useRef(false);
|
||||
|
||||
const fetchDisplays = useCallback(
|
||||
async (offset: number) => {
|
||||
const response = await getDisplaysWithContactAction({
|
||||
surveyId,
|
||||
limit: DISPLAYS_PER_PAGE,
|
||||
offset,
|
||||
});
|
||||
|
||||
if (!response?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response?.data ?? [];
|
||||
},
|
||||
[surveyId]
|
||||
);
|
||||
|
||||
const loadInitialDisplays = useCallback(async () => {
|
||||
setIsDisplaysLoading(true);
|
||||
setDisplaysError(null);
|
||||
try {
|
||||
const data = await fetchDisplays(0);
|
||||
setDisplays(data);
|
||||
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
|
||||
} catch (error) {
|
||||
toast.error(error);
|
||||
setDisplays([]);
|
||||
setHasMoreDisplays(false);
|
||||
} finally {
|
||||
setIsDisplaysLoading(false);
|
||||
}
|
||||
}, [fetchDisplays, t]);
|
||||
|
||||
const handleLoadMoreDisplays = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchDisplays(displays.length);
|
||||
setDisplays((prev) => [...prev, ...data]);
|
||||
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}, [fetchDisplays, displays.length, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "impressions" && !displaysFetchedRef.current) {
|
||||
displaysFetchedRef.current = true;
|
||||
loadInitialDisplays();
|
||||
}
|
||||
}, [tab, loadInitialDisplays]);
|
||||
|
||||
// Only fetch data when filters change or when there's no initial data
|
||||
useEffect(() => {
|
||||
// If we have initial data and no filters are applied, don't fetch
|
||||
@@ -190,18 +121,6 @@ export const SummaryPage = ({
|
||||
setTab={setTab}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
/>
|
||||
{tab === "impressions" && (
|
||||
<SummaryImpressions
|
||||
displays={displays}
|
||||
isLoading={isDisplaysLoading}
|
||||
hasMore={hasMoreDisplays}
|
||||
displaysError={displaysError}
|
||||
environmentId={environment.id}
|
||||
locale={locale}
|
||||
onLoadMore={handleLoadMoreDisplays}
|
||||
onRetry={loadInitialDisplays}
|
||||
/>
|
||||
)}
|
||||
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
|
||||
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
|
||||
<div className="flex gap-1.5">
|
||||
|
||||
@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
|
||||
|
||||
interface InteractiveCardProps {
|
||||
tab: "dropOffs" | "quotas" | "impressions";
|
||||
tab: "dropOffs" | "quotas";
|
||||
label: string;
|
||||
percentage: number | null;
|
||||
percentage: number;
|
||||
value: React.ReactNode;
|
||||
tooltipText: string;
|
||||
isLoading: boolean;
|
||||
|
||||
@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -241,7 +241,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isOpen}>
|
||||
{t("common.filter")} <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
||||
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
||||
</PopoverTriggerButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -329,7 +329,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
</div>
|
||||
{i !== filterValue.filter.length - 1 && (
|
||||
<div className="my-4 flex items-center">
|
||||
<p className="mr-4 font-semibold text-slate-800">{t("common.and")}</p>
|
||||
<p className="mr-4 font-semibold text-slate-800">and</p>
|
||||
<hr className="w-full text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import {
|
||||
OPTIONS,
|
||||
PUT,
|
||||
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -58,7 +58,7 @@ const mockProject: TJsEnvironmentStateProject = {
|
||||
inAppSurveyBranding: true,
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
darkOverlay: false,
|
||||
styling: {
|
||||
allowStyleOverwrite: false,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import {
|
||||
GET,
|
||||
OPTIONS,
|
||||
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
|
||||
|
||||
export { GET, OPTIONS };
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"] = {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import {
|
||||
OPTIONS,
|
||||
PUT,
|
||||
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -0,0 +1,6 @@
|
||||
import {
|
||||
GET,
|
||||
OPTIONS,
|
||||
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
|
||||
|
||||
export { GET, OPTIONS };
|
||||
@@ -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"] = {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"), []),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
9
apps/web/images/customer-logos/cal-logo-light.svg
Normal 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 |
BIN
apps/web/images/customer-logos/ethereum-logo.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
38
apps/web/images/customer-logos/flixbus-white.svg
Normal 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 |
BIN
apps/web/images/customer-logos/github-logo.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/web/images/customer-logos/pigment-logo.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/web/images/customer-logos/siemens.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
apps/web/images/customer-logos/university-of-copenhegen.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TDisplay, TDisplayFilters, TDisplayWithContact } from "@formbricks/types/displays";
|
||||
import { TDisplay, TDisplayFilters } from "@formbricks/types/displays";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -24,12 +23,13 @@ export const getDisplayCountBySurveyId = reactCache(
|
||||
const displayCount = await prisma.display.count({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
...(filters?.createdAt && {
|
||||
createdAt: {
|
||||
gte: filters.createdAt.min,
|
||||
lte: filters.createdAt.max,
|
||||
},
|
||||
}),
|
||||
...(filters &&
|
||||
filters.createdAt && {
|
||||
createdAt: {
|
||||
gte: filters.createdAt.min,
|
||||
lte: filters.createdAt.max,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
return displayCount;
|
||||
@@ -42,97 +42,6 @@ export const getDisplayCountBySurveyId = reactCache(
|
||||
}
|
||||
);
|
||||
|
||||
export const getDisplaysByContactId = reactCache(
|
||||
async (contactId: string): Promise<Pick<TDisplay, "id" | "createdAt" | "surveyId">[]> => {
|
||||
validateInputs([contactId, ZId]);
|
||||
|
||||
try {
|
||||
const displays = await prisma.display.findMany({
|
||||
where: { contactId },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return displays;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getDisplaysBySurveyIdWithContact = reactCache(
|
||||
async (surveyId: string, limit?: number, offset?: number): Promise<TDisplayWithContact[]> => {
|
||||
validateInputs(
|
||||
[surveyId, ZId],
|
||||
[limit, z.number().int().min(1).optional()],
|
||||
[offset, z.number().int().nonnegative().optional()]
|
||||
);
|
||||
|
||||
try {
|
||||
const displays = await prisma.display.findMany({
|
||||
where: {
|
||||
surveyId,
|
||||
contactId: { not: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: { in: ["email", "userId"] },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return displays.map((display) => ({
|
||||
id: display.id,
|
||||
createdAt: display.createdAt,
|
||||
surveyId: display.surveyId,
|
||||
contact: display.contact
|
||||
? {
|
||||
id: display.contact.id,
|
||||
attributes: display.contact.attributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
|
||||
validateInputs([displayId, ZId]);
|
||||
try {
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { mockDisplayId, mockSurveyId } from "./__mocks__/data.mock";
|
||||
import { prisma } from "@/lib/__mocks__/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { getDisplaysByContactId, getDisplaysBySurveyIdWithContact } from "../service";
|
||||
|
||||
const mockContactId = "clqnj99r9000008lebgf8734j";
|
||||
|
||||
const mockDisplaysForContact = [
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
},
|
||||
{
|
||||
id: "clqkr5smu000208jy50v6g5k5",
|
||||
createdAt: new Date("2024-01-14T10:00:00Z"),
|
||||
surveyId: "clqkr8dlv000308jybb08evgs",
|
||||
},
|
||||
];
|
||||
|
||||
const mockDisplaysWithContact = [
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: {
|
||||
id: mockContactId,
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
{ attributeKey: { key: "userId" }, value: "user-123" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clqkr5smu000208jy50v6g5k5",
|
||||
createdAt: new Date("2024-01-14T10:00:00Z"),
|
||||
surveyId: "clqkr8dlv000308jybb08evgs",
|
||||
contact: {
|
||||
id: "clqnj99r9000008lebgf8734k",
|
||||
attributes: [{ attributeKey: { key: "userId" }, value: "user-456" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("getDisplaysByContactId", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("returns displays for a contact ordered by createdAt desc", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysForContact as any);
|
||||
|
||||
const result = await getDisplaysByContactId(mockContactId);
|
||||
|
||||
expect(result).toEqual(mockDisplaysForContact);
|
||||
expect(prisma.display.findMany).toHaveBeenCalledWith({
|
||||
where: { contactId: mockContactId },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when contact has no displays", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getDisplaysByContactId(mockContactId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("throws a ValidationError if the contactId is invalid", async () => {
|
||||
await expect(getDisplaysByContactId("not-a-cuid")).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws generic Error for other exceptions", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
|
||||
|
||||
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDisplaysBySurveyIdWithContact", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("returns displays with contact attributes transformed", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysWithContact as any);
|
||||
|
||||
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: {
|
||||
id: mockContactId,
|
||||
attributes: { email: "test@example.com", userId: "user-123" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clqkr5smu000208jy50v6g5k5",
|
||||
createdAt: new Date("2024-01-14T10:00:00Z"),
|
||||
surveyId: "clqkr8dlv000308jybb08evgs",
|
||||
contact: {
|
||||
id: "clqnj99r9000008lebgf8734k",
|
||||
attributes: { userId: "user-456" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("calls prisma with correct where clause and pagination", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
|
||||
await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
|
||||
|
||||
expect(prisma.display.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
surveyId: mockSurveyId,
|
||||
contactId: { not: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: { in: ["email", "userId"] },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 15,
|
||||
skip: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no displays found", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles display with null contact", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: null,
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("throws a ValidationError if the surveyId is invalid", async () => {
|
||||
await expect(getDisplaysBySurveyIdWithContact("not-a-cuid")).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws generic Error for other exceptions", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
|
||||
|
||||
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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";
|
||||