Compare commits

...

26 Commits

Author SHA1 Message Date
Matti Nannt
762d40520c fix: resolve merge conflicts 2025-09-22 14:46:03 +02:00
Victor Hugo dos Santos
1557ffcca1 feat: add redis migration script (#6575)
Co-authored-by: Matti Nannt <matti@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-09-22 11:18:02 +00:00
Piyush Gupta
5d53ed76ed fix: logic fallback cleanup (#6568) 2025-09-22 08:10:27 +00:00
pandeymangg
e90a558f20 no translation changes 2025-09-22 13:17:36 +05:30
Dhruwang Jariwala
ebd399e611 fix: block previews for completed and paused surveys (#6576) 2025-09-22 07:21:38 +00:00
pandeymangg
f3a581376b updates script name 2025-09-22 12:36:17 +05:30
Matti Nannt
fb0cfb1eb4 docs: add more details to migration steps 2025-09-19 16:40:11 +02:00
Matti Nannt
5a723cd81a docs: add formbricks 4 migration guide 2025-09-19 15:31:10 +02:00
Dhruwang Jariwala
843110b0d6 fix: followup toast (#6565) 2025-09-19 13:03:56 +00:00
Anshuman Pandey
51babf2f98 fix: minor csp change and removes uploads volume (#6566) 2025-09-19 10:20:38 +00:00
Victor Hugo dos Santos
6bc5f1e168 feat: add cache integration tests and update E2E workflow (#6551) 2025-09-19 08:44:31 +00:00
Piyush Gupta
c9016802e7 docs: updated screenshots in docs (#6562) 2025-09-18 19:19:14 +00:00
Anshuman Pandey
6a49fb4700 feat: adds one-click MinIO migration script for Formbricks 4.0 (#6553)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-09-18 16:23:03 +00:00
Dhruwang Jariwala
646921cd37 fix: logic issues (#6561) 2025-09-18 18:31:44 +02:00
Dhruwang Jariwala
34d3145fcd fix: broken churn survey template (#6559) 2025-09-18 11:18:39 +00:00
Dhruwang Jariwala
c3c06eb309 fix: empty container in template UI (#6556) 2025-09-18 06:45:20 +00:00
Dhruwang Jariwala
bf4c6238d5 fix: api key modal tweaks (#6552)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-09-17 15:00:42 +00:00
Dhruwang Jariwala
8972ef0fef fix: integration redirect links (#6555) 2025-09-17 14:59:35 +00:00
Matti Nannt
4e59924a5a fix: e2e tests issue due to security policy (#6558) 2025-09-17 16:54:07 +02:00
Matti Nannt
8b28353b79 fix: release tag extraction in release action (#6554) 2025-09-16 17:33:32 +00:00
Matti Nannt
abbc7a065b chore: update release pipeline for new infrastructure (#6541) 2025-09-16 10:33:24 +00:00
Harsh Bhat
00e8ee27a2 docs: Add redirect error handling (#6548) 2025-09-15 06:03:41 -07:00
Dhruwang Jariwala
379aeba71a fix: synced translations (#6547) 2025-09-15 10:19:02 +00:00
Anshuman Pandey
717adddeae feat: adds docs for s3 compatible storage (#6538)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-09-15 07:34:46 +00:00
Dhruwang Jariwala
41798266a0 fix: quota translations (#6546) 2025-09-15 07:04:40 +00:00
Matti Nannt
a93fa8ec76 chore: use stable tag to manage releases and ensure one-click-setup c… (#6540) 2025-09-12 17:03:13 +00:00
122 changed files with 5798 additions and 1460 deletions

View File

@@ -0,0 +1,312 @@
name: Build and Push Docker Image
description: |
Unified Docker build and push action for both ECR and GHCR registries.
Supports:
- ECR builds for Formbricks Cloud deployment
- GHCR builds for community self-hosting
- Automatic version resolution and tagging
- Conditional signing and deployment tags
inputs:
registry_type:
description: "Registry type: 'ecr' or 'ghcr'"
required: true
# Version input
version:
description: "Explicit version (SemVer only, e.g., 1.2.3). If provided, this version is used directly. If empty, version is auto-generated from branch name."
required: false
experimental_mode:
description: "Enable experimental timestamped versions"
required: false
default: "false"
# ECR specific inputs
ecr_registry:
description: "ECR registry URL (required for ECR builds)"
required: false
ecr_repository:
description: "ECR repository name (required for ECR builds)"
required: false
ecr_region:
description: "ECR AWS region (required for ECR builds)"
required: false
aws_role_arn:
description: "AWS role ARN for ECR authentication (required for ECR builds)"
required: false
# GHCR specific inputs
ghcr_image_name:
description: "GHCR image name (required for GHCR builds)"
required: false
# Deployment options
deploy_production:
description: "Tag image for production deployment"
required: false
default: "false"
deploy_staging:
description: "Tag image for staging deployment"
required: false
default: "false"
is_prerelease:
description: "Whether this is a prerelease (auto-tags for staging/production)"
required: false
default: "false"
# Build options
dockerfile:
description: "Path to Dockerfile"
required: false
default: "apps/web/Dockerfile"
context:
description: "Build context"
required: false
default: "."
outputs:
image_tag:
description: "Resolved image tag used for the build"
value: ${{ steps.version.outputs.version }}
registry_tags:
description: "Complete registry tags that were pushed"
value: ${{ steps.build.outputs.tags }}
image_digest:
description: "Image digest from the build"
value: ${{ steps.build.outputs.digest }}
runs:
using: "composite"
steps:
- name: Validate inputs
shell: bash
env:
REGISTRY_TYPE: ${{ inputs.registry_type }}
ECR_REGISTRY: ${{ inputs.ecr_registry }}
ECR_REPOSITORY: ${{ inputs.ecr_repository }}
ECR_REGION: ${{ inputs.ecr_region }}
AWS_ROLE_ARN: ${{ inputs.aws_role_arn }}
GHCR_IMAGE_NAME: ${{ inputs.ghcr_image_name }}
run: |
set -euo pipefail
if [[ "$REGISTRY_TYPE" != "ecr" && "$REGISTRY_TYPE" != "ghcr" ]]; then
echo "ERROR: registry_type must be 'ecr' or 'ghcr', got: $REGISTRY_TYPE"
exit 1
fi
if [[ "$REGISTRY_TYPE" == "ecr" ]]; then
if [[ -z "$ECR_REGISTRY" || -z "$ECR_REPOSITORY" || -z "$ECR_REGION" || -z "$AWS_ROLE_ARN" ]]; then
echo "ERROR: ECR builds require ecr_registry, ecr_repository, ecr_region, and aws_role_arn"
exit 1
fi
fi
if [[ "$REGISTRY_TYPE" == "ghcr" ]]; then
if [[ -z "$GHCR_IMAGE_NAME" ]]; then
echo "ERROR: GHCR builds require ghcr_image_name"
exit 1
fi
fi
echo "SUCCESS: Input validation passed for $REGISTRY_TYPE build"
- name: Resolve Docker version
id: version
uses: ./.github/actions/resolve-docker-version
with:
version: ${{ inputs.version }}
current_branch: ${{ github.ref_name }}
experimental_mode: ${{ inputs.experimental_mode }}
- name: Update package.json version
uses: ./.github/actions/update-package-version
with:
version: ${{ steps.version.outputs.version }}
- name: Configure AWS credentials (ECR only)
if: ${{ inputs.registry_type == 'ecr' }}
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.2.0
with:
role-to-assume: ${{ inputs.aws_role_arn }}
aws-region: ${{ inputs.ecr_region }}
- name: Log in to Amazon ECR (ECR only)
if: ${{ inputs.registry_type == 'ecr' }}
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1
- name: Set up Docker build tools
uses: ./.github/actions/docker-build-setup
with:
registry: ${{ inputs.registry_type == 'ghcr' && 'ghcr.io' || '' }}
setup_cosign: ${{ inputs.registry_type == 'ghcr' && 'true' || 'false' }}
skip_login_on_pr: ${{ inputs.registry_type == 'ghcr' && 'true' || 'false' }}
- name: Build ECR tag list
if: ${{ inputs.registry_type == 'ecr' }}
id: ecr-tags
shell: bash
env:
IMAGE_TAG: ${{ steps.version.outputs.version }}
ECR_REGISTRY: ${{ inputs.ecr_registry }}
ECR_REPOSITORY: ${{ inputs.ecr_repository }}
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
IS_PRERELEASE: ${{ inputs.is_prerelease }}
run: |
set -euo pipefail
# Start with the base image tag
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
# Handle automatic tagging based on release type
if [[ "${IS_PRERELEASE}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
echo "Adding staging tag for prerelease"
elif [[ "${IS_PRERELEASE}" == "false" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
echo "Adding production tag for stable release"
fi
# Handle manual deployment overrides
if [[ "${DEPLOY_PRODUCTION}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
echo "Adding production tag (manual override)"
fi
if [[ "${DEPLOY_STAGING}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
echo "Adding staging tag (manual override)"
fi
echo "ECR tags generated:"
echo -e "${TAGS}"
{
echo "tags<<EOF"
echo -e "${TAGS}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"
- name: Generate additional GHCR tags for releases
if: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
id: ghcr-extra-tags
shell: bash
env:
VERSION: ${{ steps.version.outputs.version }}
IMAGE_NAME: ${{ inputs.ghcr_image_name }}
IS_PRERELEASE: ${{ inputs.is_prerelease }}
run: |
set -euo pipefail
# Start with base version tag
TAGS="ghcr.io/${IMAGE_NAME}:${VERSION}"
# For proper SemVer releases, add major.minor and major tags
if [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
# Extract major and minor versions
MAJOR=$(echo "${VERSION}" | cut -d. -f1)
MINOR=$(echo "${VERSION}" | cut -d. -f2)
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:${MAJOR}.${MINOR}"
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:${MAJOR}"
echo "Added SemVer tags: ${MAJOR}.${MINOR}, ${MAJOR}"
fi
# Add latest tag for stable releases
if [[ "${IS_PRERELEASE}" == "false" ]]; then
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:latest"
echo "Added latest tag for stable release"
fi
echo "Generated GHCR tags:"
echo -e "${TAGS}"
# Debug: Show what will be passed to Docker build
echo "DEBUG: Tags for Docker build step:"
echo -e "${TAGS}"
{
echo "tags<<EOF"
echo -e "${TAGS}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"
- name: Build GHCR metadata (experimental)
if: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' }}
id: ghcr-meta-experimental
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/${{ inputs.ghcr_image_name }}
tags: |
type=ref,event=branch
type=raw,value=${{ steps.version.outputs.version }}
- name: Debug Docker build tags
shell: bash
run: |
echo "=== DEBUG: Docker Build Configuration ==="
echo "Registry Type: ${{ inputs.registry_type }}"
echo "Experimental Mode: ${{ inputs.experimental_mode }}"
echo "Event Name: ${{ github.event_name }}"
echo "Is Prerelease: ${{ inputs.is_prerelease }}"
echo "Version: ${{ steps.version.outputs.version }}"
if [[ "${{ inputs.registry_type }}" == "ecr" ]]; then
echo "ECR Tags: ${{ steps.ecr-tags.outputs.tags }}"
elif [[ "${{ inputs.experimental_mode }}" == "true" ]]; then
echo "GHCR Experimental Tags: ${{ steps.ghcr-meta-experimental.outputs.tags }}"
else
echo "GHCR Extra Tags: ${{ steps.ghcr-extra-tags.outputs.tags }}"
fi
- name: Build and push Docker image
id: build
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ env.DEPOT_PROJECT_TOKEN }}
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ inputs.registry_type == 'ecr' && steps.ecr-tags.outputs.tags || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags) || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && steps.ghcr-extra-tags.outputs.tags) || (inputs.registry_type == 'ghcr' && format('ghcr.io/{0}:{1}', inputs.ghcr_image_name, steps.version.outputs.version)) || (inputs.registry_type == 'ecr' && format('{0}/{1}:{2}', inputs.ecr_registry, inputs.ecr_repository, steps.version.outputs.version)) }}
labels: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.labels || '' }}
secrets: |
database_url=${{ env.DUMMY_DATABASE_URL }}
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
env:
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
- name: Sign GHCR image (GHCR only)
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
shell: bash
env:
TAGS: ${{ inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags || steps.ghcr-extra-tags.outputs.tags }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
- name: Output build summary
shell: bash
env:
REGISTRY_TYPE: ${{ inputs.registry_type }}
IMAGE_TAG: ${{ steps.version.outputs.version }}
VERSION_SOURCE: ${{ steps.version.outputs.source }}
run: |
echo "SUCCESS: Built and pushed Docker image to $REGISTRY_TYPE"
echo "Image Tag: $IMAGE_TAG (source: $VERSION_SOURCE)"
if [[ "$REGISTRY_TYPE" == "ecr" ]]; then
echo "ECR Registry: ${{ inputs.ecr_registry }}"
echo "ECR Repository: ${{ inputs.ecr_repository }}"
else
echo "GHCR Image: ghcr.io/${{ inputs.ghcr_image_name }}"
fi

View File

@@ -0,0 +1,106 @@
name: Docker Build Setup
description: |
Sets up common Docker build tools and authentication with security validation.
Security Features:
- Registry URL validation
- Input sanitization
- Conditional setup based on event type
- Post-setup verification
Supports Depot CLI, Cosign signing, and Docker registry authentication.
inputs:
registry:
description: "Docker registry hostname to login to (e.g., ghcr.io, registry.example.com:5000). No paths allowed."
required: false
default: "ghcr.io"
setup_cosign:
description: "Whether to install cosign for image signing"
required: false
default: "true"
skip_login_on_pr:
description: "Whether to skip registry login on pull requests"
required: false
default: "true"
runs:
using: "composite"
steps:
- name: Validate inputs
shell: bash
env:
REGISTRY: ${{ inputs.registry }}
SETUP_COSIGN: ${{ inputs.setup_cosign }}
SKIP_LOGIN_ON_PR: ${{ inputs.skip_login_on_pr }}
run: |
set -euo pipefail
# Security: Validate registry input - must be hostname[:port] only, no paths
# Allow empty registry for cases where login is handled externally (e.g., ECR)
if [[ -n "$REGISTRY" ]]; then
if [[ "$REGISTRY" =~ / ]]; then
echo "ERROR: Invalid registry format: $REGISTRY"
echo "Registry must be host[:port] with no path (e.g., 'ghcr.io' or 'registry.example.com:5000')"
echo "Path components like 'ghcr.io/org' are not allowed as they break docker login"
exit 1
fi
# Validate hostname with optional port format
if [[ ! "$REGISTRY" =~ ^[a-zA-Z0-9.-]+(\:[0-9]+)?$ ]]; then
echo "ERROR: Invalid registry hostname format: $REGISTRY"
echo "Registry must be a valid hostname optionally with port (e.g., 'ghcr.io' or 'registry.example.com:5000')"
exit 1
fi
fi
# Validate boolean inputs
if [[ "$SETUP_COSIGN" != "true" && "$SETUP_COSIGN" != "false" ]]; then
echo "ERROR: setup_cosign must be 'true' or 'false', got: $SETUP_COSIGN"
exit 1
fi
if [[ "$SKIP_LOGIN_ON_PR" != "true" && "$SKIP_LOGIN_ON_PR" != "false" ]]; then
echo "ERROR: skip_login_on_pr must be 'true' or 'false', got: $SKIP_LOGIN_ON_PR"
exit 1
fi
echo "SUCCESS: Input validation passed"
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Install cosign
# Install cosign when requested AND when we might actually sign images
# (i.e., non-PR contexts or when we login on PRs)
if: ${{ inputs.setup_cosign == 'true' && (inputs.skip_login_on_pr == 'false' || github.event_name != 'pull_request') }}
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
- name: Log into registry
if: ${{ inputs.registry != '' && (inputs.skip_login_on_pr == 'false' || github.event_name != 'pull_request') }}
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ inputs.registry }}
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Verify setup completion
shell: bash
run: |
set -euo pipefail
# Verify Depot CLI is available
if ! command -v depot >/dev/null 2>&1; then
echo "ERROR: Depot CLI not found in PATH"
exit 1
fi
# Verify cosign if it should be installed (same conditions as install step)
if [[ "${{ inputs.setup_cosign }}" == "true" ]] && [[ "${{ inputs.skip_login_on_pr }}" == "false" || "${{ github.event_name }}" != "pull_request" ]]; then
if ! command -v cosign >/dev/null 2>&1; then
echo "ERROR: Cosign not found in PATH despite being requested"
exit 1
fi
fi
echo "SUCCESS: Docker build setup completed successfully"

View File

@@ -0,0 +1,192 @@
name: Resolve Docker Version
description: |
Resolves and validates Docker-compatible SemVer versions for container builds with comprehensive security.
Security Features:
- Command injection protection
- Input sanitization and validation
- Docker tag character restrictions
- Length limits and boundary checks
- Safe branch name handling
Supports multiple modes: release, manual override, branch auto-detection, and experimental timestamped versions.
inputs:
version:
description: "Explicit version (SemVer only, e.g., 1.2.3-beta). If provided, this version is used directly. If empty, version is auto-generated from branch name."
required: false
current_branch:
description: "Current branch name for auto-detection"
required: true
experimental_mode:
description: "Enable experimental mode with timestamp-based versions"
required: false
default: "false"
outputs:
version:
description: "Resolved Docker-compatible SemVer version"
value: ${{ steps.resolve.outputs.version }}
source:
description: "Source of version (release|override|branch)"
value: ${{ steps.resolve.outputs.source }}
normalized:
description: "Whether the version was normalized (true/false)"
value: ${{ steps.resolve.outputs.normalized }}
runs:
using: "composite"
steps:
- name: Resolve and validate Docker version
id: resolve
shell: bash
env:
EXPLICIT_VERSION: ${{ inputs.version }}
CURRENT_BRANCH: ${{ inputs.current_branch }}
EXPERIMENTAL_MODE: ${{ inputs.experimental_mode }}
run: |
set -euo pipefail
# Function to validate SemVer format (Docker-compatible, no '+' build metadata)
validate_semver() {
local version="$1"
local context="$2"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid $context format. Must be semver without build metadata (e.g., 1.2.3, 1.2.3-alpha)"
echo "Provided: $version"
echo "Note: Docker tags cannot contain '+' characters. Use prerelease identifiers instead."
exit 1
fi
}
# Function to generate branch-based version
generate_branch_version() {
local branch="$1"
local use_timestamp="${2:-true}"
local timestamp
if [[ "$use_timestamp" == "true" ]]; then
timestamp=$(date +%s)
else
timestamp=""
fi
# Sanitize branch name for Docker compatibility
local sanitized_branch=$(echo "$branch" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
# Additional safety: truncate if too long (reserve space for prefix and timestamp)
if (( ${#sanitized_branch} > 80 )); then
sanitized_branch="${sanitized_branch:0:80}"
echo "INFO: Branch name truncated for Docker compatibility" >&2
fi
local version
# Generate version based on branch name (unified approach)
# All branches get alpha versions with sanitized branch name
if [[ -n "$timestamp" ]]; then
version="0.0.0-alpha-$sanitized_branch-$timestamp"
echo "INFO: Branch '$branch' detected - alpha version: $version" >&2
else
version="0.0.0-alpha-$sanitized_branch"
echo "INFO: Branch '$branch' detected - alpha version: $version" >&2
fi
echo "$version"
}
# Input validation and sanitization
if [[ -z "$CURRENT_BRANCH" ]]; then
echo "ERROR: current_branch input is required"
exit 1
fi
# Security: Validate inputs to prevent command injection
# Use grep to check for dangerous characters (more reliable than bash regex)
validate_input() {
local input="$1"
local name="$2"
# Check for dangerous characters using grep
if echo "$input" | grep -q '[;|&`$(){}\\[:space:]]'; then
echo "ERROR: $name contains potentially dangerous characters: $input"
echo "Input should only contain letters, numbers, hyphens, underscores, dots, and forward slashes"
return 1
fi
return 0
}
# Validate current branch
if ! validate_input "$CURRENT_BRANCH" "Branch name"; then
exit 1
fi
# Validate explicit version if provided
if [[ -n "$EXPLICIT_VERSION" ]] && ! validate_input "$EXPLICIT_VERSION" "Explicit version"; then
exit 1
fi
# Main resolution logic (ultra-simplified)
NORMALIZED="false"
if [[ -n "$EXPLICIT_VERSION" ]]; then
# Use provided explicit version (from either workflow_call or manual input)
validate_semver "$EXPLICIT_VERSION" "explicit version"
# Normalize to lowercase for Docker/ECR compatibility
RESOLVED_VERSION="${EXPLICIT_VERSION,,}"
if [[ "$EXPLICIT_VERSION" != "$RESOLVED_VERSION" ]]; then
NORMALIZED="true"
echo "INFO: Original version contained uppercase characters, normalized: $EXPLICIT_VERSION -> $RESOLVED_VERSION"
fi
SOURCE="explicit"
echo "INFO: Using explicit version: $RESOLVED_VERSION"
else
# Auto-generate version from branch name
if [[ "$EXPERIMENTAL_MODE" == "true" ]]; then
# Use timestamped version generation
echo "INFO: Experimental mode: generating timestamped version from branch: $CURRENT_BRANCH"
RESOLVED_VERSION=$(generate_branch_version "$CURRENT_BRANCH" "true")
SOURCE="experimental"
else
# Standard branch version (no timestamp)
echo "INFO: Auto-detecting version from branch: $CURRENT_BRANCH"
RESOLVED_VERSION=$(generate_branch_version "$CURRENT_BRANCH" "false")
SOURCE="branch"
fi
echo "Generated version: $RESOLVED_VERSION"
fi
# Final validation - ensure result is valid Docker tag
if [[ -z "$RESOLVED_VERSION" ]]; then
echo "ERROR: Failed to resolve version"
exit 1
fi
if (( ${#RESOLVED_VERSION} > 128 )); then
echo "ERROR: Version must be at most 128 characters (Docker limitation)"
echo "Generated version: $RESOLVED_VERSION (${#RESOLVED_VERSION} chars)"
exit 1
fi
if [[ ! "$RESOLVED_VERSION" =~ ^[a-z0-9._-]+$ ]]; then
echo "ERROR: Version contains invalid characters for Docker tags"
echo "Version: $RESOLVED_VERSION"
exit 1
fi
if [[ "$RESOLVED_VERSION" =~ ^[.-] || "$RESOLVED_VERSION" =~ [.-]$ ]]; then
echo "ERROR: Version must not start or end with '.' or '-'"
echo "Version: $RESOLVED_VERSION"
exit 1
fi
# Output results
echo "SUCCESS: Resolved Docker version: $RESOLVED_VERSION (source: $SOURCE)"
echo "version=$RESOLVED_VERSION" >> $GITHUB_OUTPUT
echo "source=$SOURCE" >> $GITHUB_OUTPUT
echo "normalized=$NORMALIZED" >> $GITHUB_OUTPUT

View File

@@ -0,0 +1,160 @@
name: Update Package Version
description: |
Safely updates package.json version with comprehensive validation and atomic operations.
Security Features:
- Path traversal protection
- SemVer validation with length limits
- Atomic file operations with backup/recovery
- JSON validation before applying changes
This action is designed to be secure by default and prevent common attack vectors.
inputs:
version:
description: "Version to set in package.json (must be valid SemVer)"
required: true
package_path:
description: "Path to package.json file"
required: false
default: "./apps/web/package.json"
outputs:
updated_version:
description: "The version that was actually set in package.json"
value: ${{ steps.update.outputs.updated_version }}
runs:
using: "composite"
steps:
- name: Update and verify package.json version
id: update
shell: bash
env:
VERSION: ${{ inputs.version }}
PACKAGE_PATH: ${{ inputs.package_path }}
run: |
set -euo pipefail
# Validate inputs
if [[ -z "$VERSION" ]]; then
echo "ERROR: version input is required"
exit 1
fi
# Security: Validate package_path to prevent path traversal attacks
# Only allow paths within the workspace and must end with package.json
if [[ "$PACKAGE_PATH" =~ \.\./|^/|^~ ]]; then
echo "ERROR: Invalid package path - path traversal detected: $PACKAGE_PATH"
echo "Package path must be relative to workspace root and cannot contain '../', start with '/', or '~'"
exit 1
fi
if [[ ! "$PACKAGE_PATH" =~ package\.json$ ]]; then
echo "ERROR: Package path must end with 'package.json': $PACKAGE_PATH"
exit 1
fi
# Resolve to absolute path within workspace for additional security
WORKSPACE_ROOT="${GITHUB_WORKSPACE:-$(pwd)}"
# Use realpath to resolve both paths and handle symlinks properly
WORKSPACE_ROOT=$(realpath "$WORKSPACE_ROOT")
RESOLVED_PATH=$(realpath "${WORKSPACE_ROOT}/${PACKAGE_PATH}")
# Ensure WORKSPACE_ROOT has a trailing slash for proper prefix matching
WORKSPACE_ROOT="${WORKSPACE_ROOT}/"
# Use shell string matching to ensure RESOLVED_PATH is within workspace
# This is more secure than regex and handles edge cases properly
if [[ "$RESOLVED_PATH" != "$WORKSPACE_ROOT"* ]]; then
echo "ERROR: Resolved path is outside workspace: $RESOLVED_PATH"
echo "Workspace root: $WORKSPACE_ROOT"
exit 1
fi
if [[ ! -f "$RESOLVED_PATH" ]]; then
echo "ERROR: package.json not found at: $RESOLVED_PATH"
exit 1
fi
# Use resolved path for operations
PACKAGE_PATH="$RESOLVED_PATH"
# Validate SemVer format with additional security checks
if [[ ${#VERSION} -gt 128 ]]; then
echo "ERROR: Version string too long (${#VERSION} chars, max 128): $VERSION"
exit 1
fi
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid SemVer format: $VERSION"
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE]"
echo "Only alphanumeric characters, dots, and hyphens allowed in prerelease"
exit 1
fi
# Additional validation: Check for reasonable version component sizes
# Extract base version (MAJOR.MINOR.PATCH) without prerelease/build metadata
if [[ "$VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
else
echo "ERROR: Could not extract base version from: $VERSION"
exit 1
fi
# Split version components safely
IFS='.' read -ra VERSION_PARTS <<< "$BASE_VERSION"
# Validate component sizes (should have exactly 3 parts due to regex above)
if (( ${VERSION_PARTS[0]} > 999 || ${VERSION_PARTS[1]} > 999 || ${VERSION_PARTS[2]} > 999 )); then
echo "ERROR: Version components too large (max 999 each): $VERSION"
echo "Components: ${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}"
exit 1
fi
echo "Updating package.json version to: $VERSION"
# Create backup for atomic operations
BACKUP_PATH="${PACKAGE_PATH}.backup.$$"
cp "$PACKAGE_PATH" "$BACKUP_PATH"
# Use jq to safely update the version field with error handling
if ! jq --arg version "$VERSION" '.version = $version' "$PACKAGE_PATH" > "${PACKAGE_PATH}.tmp"; then
echo "ERROR: jq failed to process package.json"
rm -f "${PACKAGE_PATH}.tmp" "$BACKUP_PATH"
exit 1
fi
# Validate the generated JSON before applying changes
if ! jq empty "${PACKAGE_PATH}.tmp" 2>/dev/null; then
echo "ERROR: Generated invalid JSON"
rm -f "${PACKAGE_PATH}.tmp" "$BACKUP_PATH"
exit 1
fi
# Atomic move operation
if ! mv "${PACKAGE_PATH}.tmp" "$PACKAGE_PATH"; then
echo "ERROR: Failed to update package.json"
# Restore backup
mv "$BACKUP_PATH" "$PACKAGE_PATH"
exit 1
fi
# Verify the update was successful
UPDATED_VERSION=$(jq -r '.version' "$PACKAGE_PATH" 2>/dev/null)
if [[ "$UPDATED_VERSION" != "$VERSION" ]]; then
echo "ERROR: Version update failed!"
echo "Expected: $VERSION"
echo "Actual: $UPDATED_VERSION"
# Restore backup
mv "$BACKUP_PATH" "$PACKAGE_PATH"
exit 1
fi
# Clean up backup on success
rm -f "$BACKUP_PATH"
echo "SUCCESS: Updated package.json version to: $UPDATED_VERSION"
echo "updated_version=$UPDATED_VERSION" >> $GITHUB_OUTPUT

View File

@@ -1,12 +1,16 @@
name: Build & Push Docker to ECR
name: Build Cloud Deployment Images
# This workflow builds Formbricks Docker images for ECR deployment:
# - workflow_call: Used by releases with explicit SemVer versions
# - workflow_dispatch: Auto-detects version from current branch or uses override
on:
workflow_dispatch:
inputs:
image_tag:
description: "Image tag to push (e.g., v3.16.1, main)"
required: true
default: "v3.16.1"
version_override:
description: "Override version (SemVer only, e.g., 1.2.3). Leave empty to auto-detect from branch."
required: false
type: string
deploy_production:
description: "Tag image for production deployment"
required: false
@@ -17,6 +21,24 @@ on:
required: false
default: false
type: boolean
workflow_call:
inputs:
image_tag:
description: "Image tag to push (required for workflow_call)"
required: true
type: string
IS_PRERELEASE:
description: "Whether this is a prerelease (auto-tags for staging/production)"
required: false
type: boolean
default: false
outputs:
IMAGE_TAG:
description: "Normalized image tag used for the build"
value: ${{ jobs.build-and-push.outputs.IMAGE_TAG }}
TAGS:
description: "Newline-separated list of ECR tags pushed"
value: ${{ jobs.build-and-push.outputs.TAGS }}
permissions:
contents: read
@@ -27,14 +49,15 @@ env:
# ECR settings are sourced from repository/environment variables for portability across envs/forks
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
DOCKERFILE: apps/web/Dockerfile
CONTEXT: .
jobs:
build-and-push:
name: Build and Push
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
IMAGE_TAG: ${{ steps.build.outputs.image_tag }}
TAGS: ${{ steps.build.outputs.registry_tags }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
@@ -44,125 +67,22 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate image tag input
shell: bash
env:
IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
if [[ -z "${IMAGE_TAG}" ]]; then
echo "❌ Image tag is required (non-empty)."
exit 1
fi
if (( ${#IMAGE_TAG} > 128 )); then
echo "❌ Image tag must be at most 128 characters."
exit 1
fi
if [[ ! "${IMAGE_TAG}" =~ ^[a-z0-9._-]+$ ]]; then
echo "❌ Image tag may only contain lowercase letters, digits, '.', '_' and '-'."
exit 1
fi
if [[ "${IMAGE_TAG}" =~ ^[.-] || "${IMAGE_TAG}" =~ [.-]$ ]]; then
echo "❌ Image tag must not start or end with '.' or '-'."
exit 1
fi
- name: Validate required variables
shell: bash
env:
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
ECR_REGION: ${{ env.ECR_REGION }}
run: |
set -euo pipefail
if [[ -z "${ECR_REGISTRY}" || -z "${ECR_REPOSITORY}" || -z "${ECR_REGION}" ]]; then
echo "ECR_REGION, ECR_REGISTRY and ECR_REPOSITORY must be set via repository or environment variables (Settings → Variables)."
exit 1
fi
- name: Update package.json version
shell: bash
env:
IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
# Remove 'v' prefix if present (e.g., v3.16.1 -> 3.16.1)
VERSION="${IMAGE_TAG#v}"
# Validate SemVer format (major.minor.patch with optional prerelease and build metadata)
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid version format after extraction. Must be SemVer (e.g., 1.2.3, 1.2.3-alpha, 1.2.3+build.1)"
echo "Original input: ${IMAGE_TAG}"
echo "Extracted version: ${VERSION}"
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILDMETADATA]"
exit 1
fi
echo "✅ Valid SemVer format detected: ${VERSION}"
echo "Updating package.json version to: ${VERSION}"
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${VERSION}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Build tag list
id: tags
shell: bash
env:
IMAGE_TAG: ${{ inputs.image_tag }}
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
run: |
set -euo pipefail
# Start with the base image tag
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
# Add production tag if requested
if [[ "${DEPLOY_PRODUCTION}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
fi
# Add staging tag if requested
if [[ "${DEPLOY_STAGING}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
fi
# Output for debugging
echo "Generated tags:"
echo -e "${TAGS}"
# Set output for next step (escape newlines for GitHub Actions)
{
echo "tags<<EOF"
echo -e "${TAGS}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
- name: Build and push cloud deployment image
id: build
uses: ./.github/actions/build-and-push-docker
with:
role-to-assume: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
aws-region: ${{ env.ECR_REGION }}
- name: Log in to Amazon ECR
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Build and push image (Depot remote builder)
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: ${{ env.CONTEXT }}
file: ${{ env.DOCKERFILE }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.tags }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
registry_type: "ecr"
ecr_registry: ${{ env.ECR_REGISTRY }}
ecr_repository: ${{ env.ECR_REPOSITORY }}
ecr_region: ${{ env.ECR_REGION }}
aws_role_arn: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
version: ${{ inputs.version_override || inputs.image_tag }}
deploy_production: ${{ inputs.deploy_production }}
deploy_staging: ${{ inputs.deploy_staging }}
is_prerelease: ${{ inputs.IS_PRERELEASE }}
env:
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
VERSION:
description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0."
description: "The version of the Docker image to release (clean SemVer, e.g., 1.2.3)"
required: true
type: string
REPOSITORY:

View File

@@ -33,7 +33,7 @@ jobs:
timeout-minutes: 60
services:
postgres:
image: pgvector/pgvector:pg17
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
@@ -41,7 +41,7 @@ jobs:
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U testuser"
--health-cmd="pg_isready -U postgres"
--health-interval=10s
--health-timeout=5s
--health-retries=5
@@ -49,25 +49,15 @@ jobs:
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
ports:
- 6379:6379
minio:
image: bitnami/minio:2025.7.23-debian-12-r5
env:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- 9000:9000
options: >-
--health-cmd="curl -fsS http://localhost:9000/minio/health/live || exit 1"
--health-interval=10s
--health-timeout=5s
--health-retries=20
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: allow
egress-policy: audit
allowed-endpoints: |
ee.formbricks.com:443
registry-1.docker.io:443
docker.io:443
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
@@ -101,8 +91,8 @@ jobs:
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=minioadmin" >> .env
echo "S3_SECRET_KEY=minioadmin" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
@@ -122,6 +112,22 @@ jobs:
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
run: |
set -euo pipefail
# Start MinIO server in background
docker run -d \
--name minio-server \
-p 9000:9000 \
-p 9001:9001 \
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
echo "MinIO server started"
- name: Wait for MinIO and create S3 bucket
run: |
set -euo pipefail
@@ -142,7 +148,7 @@ jobs:
exit 1
fi
mc alias set local http://localhost:9000 minioadmin minioadmin
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
- name: Build App
@@ -160,6 +166,12 @@ jobs:
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
shell: bash
- name: Run Cache Integration Tests
run: |
echo "Running cache integration tests with Redis/Valkey..."
cd packages/cache && pnpm vitest run src/cache-integration.test.ts
shell: bash
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)

View File

@@ -8,8 +8,8 @@ permissions:
contents: read
jobs:
docker-build:
name: Build & release docker image
docker-build-community:
name: Build & release community docker image
permissions:
contents: read
packages: write
@@ -19,6 +19,19 @@ jobs:
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
docker-build-cloud:
name: Build & push Formbricks Cloud to ECR
permissions:
contents: read
id-token: write
uses: ./.github/workflows/build-and-push-ecr.yml
secrets: inherit
with:
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
needs:
- docker-build-community
helm-chart-release:
name: Release Helm Chart
permissions:
@@ -27,22 +40,42 @@ jobs:
uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit
needs:
- docker-build
- docker-build-community
with:
VERSION: ${{ needs.docker-build.outputs.VERSION }}
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud
permissions:
contents: read
id-token: write
secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml
verify-cloud-build:
name: Verify Cloud Build Outputs
runs-on: ubuntu-latest
timeout-minutes: 5 # Simple verification should be quick
needs:
- docker-build
- helm-chart-release
- docker-build-cloud
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Display ECR build outputs
env:
IMAGE_TAG: ${{ needs.docker-build-cloud.outputs.IMAGE_TAG }}
TAGS: ${{ needs.docker-build-cloud.outputs.TAGS }}
run: |
set -euo pipefail
echo "✅ ECR Build Completed Successfully"
echo "Image Tag: ${IMAGE_TAG}"
echo "ECR Tags:"
printf '%s\n' "${TAGS}"
move-stable-tag:
name: Move stable tag to release
permissions:
contents: write # Required for tag push operations in called workflow
uses: ./.github/workflows/move-stable-tag.yml
needs:
- docker-build-community # Ensure release is successful first
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
release_tag: ${{ github.event.release.tag_name }}
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}

96
.github/workflows/move-stable-tag.yml vendored Normal file
View File

@@ -0,0 +1,96 @@
name: Move Stable Tag
on:
workflow_call:
inputs:
release_tag:
description: "The release tag name (e.g., v1.2.3)"
required: true
type: string
commit_sha:
description: "The commit SHA to point the stable tag to"
required: true
type: string
is_prerelease:
description: "Whether this is a prerelease (stable tag won't be moved for prereleases)"
required: false
type: boolean
default: false
permissions:
contents: read
# Prevent concurrent stable tag operations to avoid race conditions
concurrency:
group: move-stable-tag-${{ github.repository }}
cancel-in-progress: true
jobs:
move-stable-tag:
name: Move stable tag to release
runs-on: ubuntu-latest
timeout-minutes: 10 # Prevent hung git operations
permissions:
contents: write # Required to push tags
# Only move stable tag for non-prerelease versions
if: ${{ !inputs.is_prerelease }}
steps:
- name: Harden the runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0 # Full history needed for tag operations
- name: Validate inputs
env:
RELEASE_TAG: ${{ inputs.release_tag }}
COMMIT_SHA: ${{ inputs.commit_sha }}
run: |
set -euo pipefail
# Validate release tag format
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format. Expected format: v1.2.3, v1.2.3-alpha"
echo "Provided: $RELEASE_TAG"
exit 1
fi
# Validate commit SHA format (40 character hex)
if [[ ! "$COMMIT_SHA" =~ ^[a-f0-9]{40}$ ]]; then
echo "❌ Error: Invalid commit SHA format. Expected 40 character hex string"
echo "Provided: $COMMIT_SHA"
exit 1
fi
echo "✅ Input validation passed"
echo "Release tag: $RELEASE_TAG"
echo "Commit SHA: $COMMIT_SHA"
- name: Move stable tag
env:
RELEASE_TAG: ${{ inputs.release_tag }}
COMMIT_SHA: ${{ inputs.commit_sha }}
run: |
set -euo pipefail
# Configure git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Verify the commit exists
if ! git cat-file -e "$COMMIT_SHA"; then
echo "❌ Error: Commit $COMMIT_SHA does not exist in this repository"
exit 1
fi
# Move stable tag to the release commit
echo "📌 Moving stable tag to commit: $COMMIT_SHA (release: $RELEASE_TAG)"
git tag -f stable "$COMMIT_SHA"
git push origin stable --force
echo "✅ Successfully moved stable tag to release $RELEASE_TAG"
echo "🔗 Stable tag now points to: https://github.com/${{ github.repository }}/commit/$COMMIT_SHA"

View File

@@ -1,39 +1,31 @@
name: Docker Release to Github Experimental
name: Build Community Testing Images
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow builds experimental/testing versions of Formbricks for self-hosting customers
# to test fixes and features before official releases. Images are pushed to GHCR with
# timestamped experimental versions for easy identification and testing.
on:
workflow_dispatch:
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}-experimental
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
inputs:
version_override:
description: "Override version (SemVer only, e.g., 1.2.3-beta). Leave empty for auto-generated experimental version."
required: false
type: string
permissions:
contents: read
packages: write
id-token: write
jobs:
build:
build-community-testing:
name: Build Community Testing Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
timeout-minutes: 45
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
@@ -42,110 +34,17 @@ jobs:
with:
fetch-depth: 0
- name: Generate SemVer version from branch or tag
id: generate_version
- name: Build and push community testing image
uses: ./.github/actions/build-and-push-docker
with:
registry_type: "ghcr"
ghcr_image_name: "${{ github.repository }}-experimental"
experimental_mode: "true"
version: ${{ inputs.version_override }}
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
# Get reference name and type from environment variables
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"
# Create unique timestamped version for testing sourcemap resolution
TIMESTAMP=$(date +%s)
if [[ "$REF_TYPE" == "tag" ]]; then
# If running from a tag, use the tag name + timestamp
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
BASE_VERSION=$(echo "$REF_NAME" | sed 's/^v//')
VERSION="${BASE_VERSION}-${TIMESTAMP}"
echo "Using SemVer tag with timestamp: $VERSION"
else
# Tag is not SemVer, treat as prerelease
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-${SANITIZED_TAG}-${TIMESTAMP}"
echo "Using tag as prerelease with timestamp: $VERSION"
fi
else
# Running from branch, use branch name as prerelease + timestamp
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-${SANITIZED_BRANCH}-${TIMESTAMP}"
echo "Using branch as prerelease with timestamp: $VERSION"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Generated SemVer version: $VERSION"
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=raw,value=${{ env.VERSION }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -1,4 +1,4 @@
name: Docker Release to Github
name: Release Community Docker Images
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
@@ -23,8 +23,6 @@ env:
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
@@ -32,6 +30,7 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
@@ -44,103 +43,60 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get Release Tag
- name: Extract release version from tag
id: extract_release_tag
run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG="$GITHUB_REF"
TAG=${TAG#refs/tags/v}
set -euo pipefail
# Validate the extracted tag format
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Original ref: $GITHUB_REF"
echo "Extracted tag: $TAG"
# Extract tag name with fallback logic for different trigger contexts
if [[ -n "${RELEASE_TAG:-}" ]]; then
TAG="$RELEASE_TAG"
echo "Using RELEASE_TAG override: $TAG"
elif [[ "$GITHUB_REF_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] || [[ "$GITHUB_REF_NAME" =~ ^v[0-9] ]]; then
TAG="$GITHUB_REF_NAME"
echo "Using GITHUB_REF_NAME (looks like tag): $TAG"
else
# Fallback: extract from GITHUB_REF for direct tag triggers
TAG="${GITHUB_REF#refs/tags/}"
if [[ -z "$TAG" || "$TAG" == "$GITHUB_REF" ]]; then
TAG="$GITHUB_REF_NAME"
echo "Using GITHUB_REF_NAME as final fallback: $TAG"
else
echo "Extracted from GITHUB_REF: $TAG"
fi
fi
# Strip v-prefix if present (normalize to clean SemVer)
TAG=${TAG#[vV]}
# Validate SemVer format (supports prereleases like 4.0.0-rc.1)
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid tag format '$TAG'. Expected SemVer (e.g., 1.2.3, 4.0.0-rc.1)"
exit 1
fi
# Safely add to environment variables
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"
echo "Using version: $TAG"
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
- name: Build and push community release image
id: build
uses: ./.github/actions/build-and-push-docker
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Default semver tags (version, major.minor, major)
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# Only tag as 'latest' for stable releases (not prereleases)
type=raw,value=latest,enable=${{ !inputs.IS_PRERELEASE }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
registry_type: "ghcr"
ghcr_image_name: ${{ env.IMAGE_NAME }}
version: ${{ steps.extract_release_tag.outputs.VERSION }}
is_prerelease: ${{ inputs.IS_PRERELEASE }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -19,7 +19,7 @@ jobs:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
@@ -59,14 +59,35 @@ jobs:
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
env:
VERSION: ${{ env.VERSION }}
run: |
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
set -euo pipefail
echo "Updating Chart.yaml with version: ${VERSION}"
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
echo "✅ Successfully updated Chart.yaml"
- name: Package Helm chart
env:
VERSION: ${{ env.VERSION }}
run: |
set -euo pipefail
echo "Packaging Helm chart version: ${VERSION}"
helm package ./helm-chart
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
- name: Push Helm chart to GitHub Container Registry
env:
VERSION: ${{ env.VERSION }}
run: |
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts
set -euo pipefail
echo "Pushing Helm chart to registry: formbricks-${VERSION}.tgz"
helm push "formbricks-${VERSION}.tgz" oci://ghcr.io/formbricks/helm-charts
echo "✅ Successfully pushed Helm chart to registry"

View File

@@ -31,6 +31,6 @@ describe("IntegrationsTip", () => {
const linkElement = screen.getByText("environments.settings.notifications.use_the_integration");
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute("href", `/environments/${environmentId}/integrations`);
expect(linkElement).toHaveAttribute("href", `/environments/${environmentId}/project/integrations`);
});
});

View File

@@ -16,7 +16,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
<p className="text-sm">
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
<a
href={`/environments/${environmentId}/integrations`}
href={`/environments/${environmentId}/project/integrations`}
className="ml-1 cursor-pointer text-sm underline">
{t("environments.settings.notifications.use_the_integration")}
</a>

View File

@@ -75,7 +75,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
{t("environments.surveys.summary.configure_alerts")}
</Link>
<Link
href={`/environments/${environmentId}/integrations`}
href={`/environments/${environmentId}/project/integrations`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.setup_integrations")}

View File

@@ -357,7 +357,10 @@ const buildNotionPayloadProperties = (
// notion requires specific payload for each column type
// * TYPES NOT SUPPORTED BY NOTION API - rollup, created_by, created_time, last_edited_by, or last_edited_time
const getValue = (colType: string, value: string | string[] | Date | number | Record<string, string>) => {
const getValue = (
colType: string,
value: string | string[] | Date | number | Record<string, string> | undefined
) => {
try {
switch (colType) {
case "select":

View File

@@ -62,9 +62,10 @@ export const GET = async (req: Request) => {
};
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
if (result) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/google-sheets`);
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/google-sheets`
);
}
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");

View File

@@ -90,7 +90,9 @@ export const GET = withV1ApiWrapper({
};
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`),
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/airtable`
),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");

View File

@@ -86,13 +86,15 @@ export const GET = withV1ApiWrapper({
if (result) {
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`),
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/notion`
),
};
}
} else if (error) {
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/notion?error=${error}`
),
};
}

View File

@@ -93,13 +93,15 @@ export const GET = withV1ApiWrapper({
if (result) {
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`),
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/slack`
),
};
}
} else if (error) {
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/slack?error=${error}`
),
};
}

View File

@@ -0,0 +1 @@
export { GET } from "@/modules/api/v2/health/route";

View File

@@ -1,6 +1,5 @@
import { describe, expect, test } from "vitest";
import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTemplateRole } from "@formbricks/types/templates";
import {
buildCTAQuestion,
buildConsentQuestion,

View File

@@ -19,7 +19,7 @@ import {
TSurveyRatingQuestion,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { TTemplate } from "@formbricks/types/templates";
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
const getDefaultButtonLabel = (label: string | undefined, t: TFnType) =>
createI18nString(label || t("common.next"), []);
@@ -391,6 +391,7 @@ export const buildSurvey = (
name: string;
industries: ("eCommerce" | "saas" | "other")[];
channels: ("link" | "app" | "website")[];
role: TTemplateRole;
description: string;
questions: TSurveyQuestion[];
endings?: TSurveyEnding[];
@@ -403,6 +404,7 @@ export const buildSurvey = (
name: config.name,
industries: config.industries,
channels: config.channels,
role: config.role,
description: config.description,
preset: {
...localSurvey,

View File

@@ -24,6 +24,7 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.card_abandonment_survey"),
role: "productManager",
industries: ["eCommerce"],
channels: ["app", "website", "link"],
description: t("templates.card_abandonment_survey_description"),
@@ -124,6 +125,7 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.site_abandonment_survey"),
role: "productManager",
industries: ["eCommerce"],
channels: ["app", "website"],
description: t("templates.site_abandonment_survey_description"),
@@ -221,6 +223,7 @@ const productMarketFitSuperhuman = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.product_market_fit_superhuman"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.product_market_fit_superhuman_description"),
@@ -295,6 +298,7 @@ const onboardingSegmentation = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.onboarding_segmentation"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.onboarding_segmentation_description"),
@@ -358,6 +362,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.churn_survey"),
role: "sales",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link"],
description: t("templates.churn_survey_description"),
@@ -447,6 +452,7 @@ const earnedAdvocacyScore = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.earned_advocacy_score_name"),
role: "customerSuccess",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link"],
description: t("templates.earned_advocacy_score_description"),
@@ -519,6 +525,7 @@ const usabilityScoreRatingSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.usability_score_name"),
role: "customerSuccess",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.usability_rating_description"),
@@ -644,6 +651,7 @@ const improveTrialConversion = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.improve_trial_conversion_name"),
role: "sales",
industries: ["saas"],
channels: ["link", "app"],
description: t("templates.improve_trial_conversion_description"),
@@ -745,6 +753,7 @@ const reviewPrompt = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.review_prompt_name"),
role: "marketing",
industries: ["saas", "eCommerce", "other"],
channels: ["link", "app"],
description: t("templates.review_prompt_description"),
@@ -823,6 +832,7 @@ const interviewPrompt = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.interview_prompt_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.interview_prompt_description"),
@@ -850,6 +860,7 @@ const improveActivationRate = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.improve_activation_rate_name"),
role: "productManager",
industries: ["saas"],
channels: ["link"],
description: t("templates.improve_activation_rate_description"),
@@ -940,6 +951,7 @@ const employeeSatisfaction = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.employee_satisfaction_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link"],
description: t("templates.employee_satisfaction_description"),
@@ -1017,6 +1029,7 @@ const uncoverStrengthsAndWeaknesses = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.uncover_strengths_and_weaknesses_name"),
role: "productManager",
industries: ["saas", "other"],
channels: ["app", "link"],
description: t("templates.uncover_strengths_and_weaknesses_description"),
@@ -1070,6 +1083,7 @@ const productMarketFitShort = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.product_market_fit_short_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.product_market_fit_short_description"),
@@ -1106,6 +1120,7 @@ const marketAttribution = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.market_attribution_name"),
role: "marketing",
industries: ["saas", "eCommerce"],
channels: ["website", "app", "link"],
description: t("templates.market_attribution_description"),
@@ -1136,6 +1151,7 @@ const changingSubscriptionExperience = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.changing_subscription_experience_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.changing_subscription_experience_description"),
@@ -1178,6 +1194,7 @@ const identifyCustomerGoals = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.identify_customer_goals_name"),
role: "productManager",
industries: ["saas", "other"],
channels: ["app", "website"],
description: t("templates.identify_customer_goals_description"),
@@ -1207,6 +1224,7 @@ const featureChaser = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.feature_chaser_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.feature_chaser_description"),
@@ -1245,6 +1263,7 @@ const fakeDoorFollowUp = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.fake_door_follow_up_name"),
role: "productManager",
industries: ["saas", "eCommerce"],
channels: ["app", "website"],
description: t("templates.fake_door_follow_up_description"),
@@ -1288,6 +1307,7 @@ const feedbackBox = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.feedback_box_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.feedback_box_description"),
@@ -1357,6 +1377,7 @@ const integrationSetupSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.integration_setup_survey_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.integration_setup_survey_description"),
@@ -1429,6 +1450,7 @@ const newIntegrationSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.new_integration_survey_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.new_integration_survey_description"),
@@ -1460,6 +1482,7 @@ const docsFeedback = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.docs_feedback_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "website", "link"],
description: t("templates.docs_feedback_description"),
@@ -1499,6 +1522,7 @@ const nps = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.nps_name"),
role: "customerSuccess",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link", "website"],
description: t("templates.nps_description"),
@@ -1539,6 +1563,7 @@ const customerSatisfactionScore = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.csat_name"),
role: "customerSuccess",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link", "website"],
description: t("templates.csat_description"),
@@ -1707,6 +1732,7 @@ const collectFeedback = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.collect_feedback_name"),
role: "productManager",
industries: ["other", "eCommerce"],
channels: ["website", "link"],
description: t("templates.collect_feedback_description"),
@@ -1853,6 +1879,7 @@ const identifyUpsellOpportunities = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.identify_upsell_opportunities_name"),
role: "sales",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.identify_upsell_opportunities_description"),
@@ -1882,6 +1909,7 @@ const prioritizeFeatures = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.prioritize_features_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.prioritize_features_description"),
@@ -1934,6 +1962,7 @@ const gaugeFeatureSatisfaction = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.gauge_feature_satisfaction_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.gauge_feature_satisfaction_description"),
@@ -1967,6 +1996,7 @@ const marketSiteClarity = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.market_site_clarity_name"),
role: "marketing",
industries: ["saas", "eCommerce", "other"],
channels: ["website"],
description: t("templates.market_site_clarity_description"),
@@ -2008,6 +2038,7 @@ const customerEffortScore = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.customer_effort_score_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.customer_effort_score_description"),
@@ -2039,6 +2070,7 @@ const careerDevelopmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.career_development_survey_name"),
role: "productManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.career_development_survey_description"),
@@ -2125,6 +2157,7 @@ const professionalDevelopmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.professional_development_survey_name"),
role: "productManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.professional_development_survey_description"),
@@ -2212,6 +2245,7 @@ const rateCheckoutExperience = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.rate_checkout_experience_name"),
role: "productManager",
industries: ["eCommerce"],
channels: ["website", "app"],
description: t("templates.rate_checkout_experience_description"),
@@ -2288,6 +2322,7 @@ const measureSearchExperience = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.measure_search_experience_name"),
role: "productManager",
industries: ["saas", "eCommerce"],
channels: ["app", "website"],
description: t("templates.measure_search_experience_description"),
@@ -2364,6 +2399,7 @@ const evaluateContentQuality = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.evaluate_content_quality_name"),
role: "marketing",
industries: ["other"],
channels: ["website"],
description: t("templates.evaluate_content_quality_description"),
@@ -2441,6 +2477,7 @@ const measureTaskAccomplishment = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.measure_task_accomplishment_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "website"],
description: t("templates.measure_task_accomplishment_description"),
@@ -2623,6 +2660,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.identify_sign_up_barriers_name"),
role: "marketing",
industries: ["saas", "eCommerce", "other"],
channels: ["website"],
description: t("templates.identify_sign_up_barriers_description"),
@@ -2774,6 +2812,7 @@ const buildProductRoadmap = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.build_product_roadmap_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.build_product_roadmap_description"),
@@ -2808,6 +2847,7 @@ const understandPurchaseIntention = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.understand_purchase_intention_name"),
role: "sales",
industries: ["eCommerce"],
channels: ["website", "link", "app"],
description: t("templates.understand_purchase_intention_description"),
@@ -2863,6 +2903,7 @@ const improveNewsletterContent = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.improve_newsletter_content_name"),
role: "marketing",
industries: ["eCommerce", "saas", "other"],
channels: ["link"],
description: t("templates.improve_newsletter_content_description"),
@@ -2953,6 +2994,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.evaluate_a_product_idea_name"),
role: "productManager",
industries: ["saas", "other"],
channels: ["link", "app"],
description: t("templates.evaluate_a_product_idea_description"),
@@ -3055,6 +3097,7 @@ const understandLowEngagement = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.understand_low_engagement_name"),
role: "productManager",
industries: ["saas"],
channels: ["link"],
description: t("templates.understand_low_engagement_description"),
@@ -3140,6 +3183,7 @@ const employeeWellBeing = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.employee_well_being_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.employee_well_being_description"),
@@ -3189,6 +3233,7 @@ const longTermRetentionCheckIn = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.long_term_retention_check_in_name"),
role: "peopleManager",
industries: ["saas", "other"],
channels: ["app", "link"],
description: t("templates.long_term_retention_check_in_description"),
@@ -3297,6 +3342,7 @@ const professionalDevelopmentGrowth = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.professional_development_growth_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.professional_development_growth_survey_description"),
@@ -3346,6 +3392,7 @@ const recognitionAndReward = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.recognition_and_reward_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.recognition_and_reward_survey_description"),
@@ -3394,6 +3441,7 @@ const alignmentAndEngagement = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.alignment_and_engagement_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.alignment_and_engagement_survey_description"),
@@ -3442,6 +3490,7 @@ const supportiveWorkCulture = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.supportive_work_culture_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.supportive_work_culture_survey_description"),

View File

@@ -1,11 +1,11 @@
import { parseRecallInfo } from "@/lib/utils/recall";
import { TResponse } from "@formbricks/types/responses";
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
export const convertResponseValue = (
answer: string | number | string[] | Record<string, string>,
answer: TResponseDataValue,
question: TSurveyQuestion
): string | string[] => {
switch (question.type) {
@@ -57,9 +57,7 @@ export const getQuestionResponseMapping = (
return questionResponseMapping;
};
export const processResponseData = (
responseData: string | number | string[] | Record<string, string>
): string => {
export const processResponseData = (responseData: TResponseDataValue): string => {
switch (typeof responseData) {
case "string":
return responseData;

View File

@@ -450,7 +450,7 @@ const evaluateSingleCondition = (
return (
Array.isArray(leftValue) &&
Array.isArray(rightValue) &&
rightValue.some((v) => !leftValue.includes(v))
!rightValue.some((v) => leftValue.includes(v))
);
case "isAccepted":
return leftValue === "accepted";

View File

@@ -169,11 +169,14 @@
"connect_formbricks": "Formbricks verbinden",
"connected": "Verbunden",
"contacts": "Kontakte",
"continue": "Weitermachen",
"copied": "Kopiert",
"copied_to_clipboard": "In die Zwischenablage kopiert",
"copy": "Kopieren",
"copy_code": "Code kopieren",
"copy_link": "Link kopieren",
"count_contacts": "{value, plural, other {'{'value, plural,\none '{{#}' Kontakt'}'\nother '{{#}' Kontakte'}'\n'}'}}",
"count_responses": "{value, plural, other {{count} Antworten}}",
"create_new_organization": "Neue Organisation erstellen",
"create_project": "Projekt erstellen",
"create_segment": "Segment erstellen",
@@ -201,6 +204,7 @@
"e_commerce": "E-Commerce",
"edit": "Bearbeiten",
"email": "E-Mail",
"ending_card": "Abschluss-Karte",
"enterprise_license": "Enterprise Lizenz",
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
@@ -269,6 +273,7 @@
"no_background_image_found": "Kein Hintergrundbild gefunden.",
"no_code": "No Code",
"no_files_uploaded": "Keine Dateien hochgeladen",
"no_quotas_found": "Keine Kontingente gefunden",
"no_result_found": "Kein Ergebnis gefunden",
"no_results": "Keine Ergebnisse",
"no_surveys_found": "Keine Umfragen gefunden.",
@@ -312,6 +317,7 @@
"product_manager": "Produktmanager",
"profile": "Profil",
"profile_id": "Profil-ID",
"progress": "Fortschritt",
"project_configuration": "Projekteinstellungen",
"project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"project_id": "Projekt-ID",
@@ -323,6 +329,9 @@
"question": "Frage",
"question_id": "Frage-ID",
"questions": "Fragen",
"quota": "Kontingent",
"quotas": "Quoten",
"quotas_description": "Begrenze die Anzahl der Antworten, die du von Teilnehmern erhältst, die bestimmte Kriterien erfüllen.",
"read_docs": "Dokumentation lesen",
"recipients": "Empfänger",
"remove": "Entfernen",
@@ -370,6 +379,7 @@
"start_free_trial": "Kostenlos starten",
"status": "Status",
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
"styling": "Styling",
"submit": "Abschicken",
"summary": "Zusammenfassung",
@@ -579,6 +589,7 @@
"contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
"delete_contact_confirmation_with_quotas": "{value, plural, other {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn dieser Kontakt Antworten hat, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.}}",
"no_responses_found": "Keine Antworten gefunden",
"not_provided": "Nicht angegeben",
"search_contact": "Kontakt suchen",
@@ -739,7 +750,6 @@
},
"project": {
"api_keys": {
"access_control": "Zugriffskontrolle",
"add_api_key": "API-Schlüssel hinzufügen",
"api_key": "API-Schlüssel",
"api_key_copied_to_clipboard": "API-Schlüssel in die Zwischenablage kopiert",
@@ -1280,7 +1290,7 @@
"columns": "Spalten",
"company": "Firma",
"company_logo": "Firmenlogo",
"completed_responses": "unvollständige oder vollständige Antworten.",
"completed_responses": "Abgeschlossene Antworten.",
"concat": "Verketten +",
"conditional_logic": "Bedingte Logik",
"confirm_default_language": "Standardsprache bestätigen",
@@ -1320,6 +1330,7 @@
"end_screen_card": "Abschluss-Karte",
"ending_card": "Abschluss-Karte",
"ending_card_used_in_logic": "Diese Abschlusskarte wird in der Logik der Frage {questionIndex} verwendet.",
"ending_used_in_quota": "Dieses Ende wird in der \"{quotaName}\" Quote verwendet",
"ends_with": "endet mit",
"equals": "Gleich",
"equals_one_of": "Entspricht einem von",
@@ -1330,6 +1341,7 @@
"fallback_for": "Ersatz für",
"fallback_missing": "Fehlender Fallback",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Verstecktes Feld \"{fieldId}\" wird in der \"{quotaName}\" Quote verwendet",
"field_name_eg_score_price": "Feldname z.B. Punktzahl, Preis",
"first_name": "Vorname",
"five_points_recommended": "5 Punkte (empfohlen)",
@@ -1361,8 +1373,9 @@
"follow_ups_modal_action_subject_placeholder": "Betreff der E-Mail",
"follow_ups_modal_action_to_description": "Empfänger-E-Mail-Adresse",
"follow_ups_modal_action_to_label": "An",
"follow_ups_modal_action_to_warning": "Kein E-Mail-Feld in der Umfrage gefunden.",
"follow_ups_modal_action_to_warning": "Keine gültigen Optionen für den Versand von E-Mails gefunden, bitte fügen Sie einige Freitext- / Kontaktinformationen-Fragen oder versteckte Felder hinzu",
"follow_ups_modal_create_heading": "Neues Follow-up erstellen",
"follow_ups_modal_created_successfull_toast": "Nachverfolgung erstellt und wird gespeichert, sobald du die Umfrage speicherst.",
"follow_ups_modal_edit_heading": "Follow-up bearbeiten",
"follow_ups_modal_edit_no_id": "Keine Survey Follow-up-ID angegeben, das Survey-Follow-up kann nicht aktualisiert werden",
"follow_ups_modal_name_label": "Name des Follow-ups",
@@ -1372,8 +1385,9 @@
"follow_ups_modal_trigger_label": "Auslöser",
"follow_ups_modal_trigger_type_ending": "Teilnehmer sieht einen bestimmten Abschluss",
"follow_ups_modal_trigger_type_ending_select": "Abschlüsse auswählen: ",
"follow_ups_modal_trigger_type_ending_warning": "Keine Abschlüsse in der Umfrage gefunden!",
"follow_ups_modal_trigger_type_ending_warning": "Bitte wähle mindestens ein Ende aus oder ändere den Auslöser-Typ",
"follow_ups_modal_trigger_type_response": "Teilnehmer schließt Umfrage ab",
"follow_ups_modal_updated_successfull_toast": "Nachverfolgung aktualisiert und wird gespeichert, sobald du die Umfrage speicherst.",
"follow_ups_new": "Neues Follow-up",
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
"form_styling": "Umfrage Styling",
@@ -1474,6 +1488,38 @@
"question_duplicated": "Frage dupliziert.",
"question_id_updated": "Frage-ID aktualisiert",
"question_used_in_logic": "Diese Frage wird in der Logik der Frage {questionIndex} verwendet.",
"question_used_in_quota": "Diese Frage wird in der \"{quotaName}\" Quote verwendet",
"quotas": {
"add_quota": "Quote hinzufügen",
"change_quota_for_public_survey": "Quote für öffentliche Umfrage ändern?",
"confirm_quota_changes": "Änderungen der Quoten bestätigen",
"confirm_quota_changes_body": "Du hast ungespeicherte Änderungen in deinem Kontingent. Möchtest Du sie speichern, bevor Du gehst?",
"continue_survey_normally": "Umfrage normal fortsetzen",
"count_partial_submissions": "Teilweise Abgaben zählen",
"count_partial_submissions_description": "Einschließlich Teilnehmer, die die Quotenanforderungen erfüllen, aber die Umfrage nicht abgeschlossen haben",
"create_quota_for_public_survey": "Quote für öffentliche Umfrage erstellen?",
"create_quota_for_public_survey_description": "Nur zukünftige Antworten werden für das Kontingent berücksichtigt",
"create_quota_for_public_survey_text": "Diese Umfrage ist bereits öffentlich. Bestehende Antworten werden für die neue Quote nicht berücksichtigt.",
"delete_quota_confirmation_text": "Dies wird die Quote {quotaName} dauerhaft löschen.",
"duplicate_quota": "Duplizieren der Quote",
"edit_quota": "Bearbeite Quote",
"end_survey_for_matching_participants": "Umfrage für passende Teilnehmer beenden",
"inclusion_criteria": "Einschlusskriterien",
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {Limit muss größer oder gleich der Anzahl der Antworten sein}}",
"limited_to_x_responses": "Begrenzt auf {limit}",
"new_quota": "Neues Kontingent",
"quota_created_successfull_toast": "Kontingent erfolgreich erstellt",
"quota_deleted_successfull_toast": "Kontingent erfolgreich gelöscht",
"quota_duplicated_successfull_toast": "Kontingent erfolgreich dupliziert",
"quota_name_placeholder": "z.B., Teilnehmende im Alter von 18-25",
"quota_updated_successfull_toast": "Kontingent erfolgreich aktualisiert",
"response_limit": "Grenzen",
"save_changes_confirmation_body": "Jegliche Änderungen an den Einschlusskriterien betreffen nur zukünftige Antworten.\nWir empfehlen, entweder ein bestehendes Kontingent zu duplizieren oder ein neues zu erstellen.",
"save_changes_confirmation_text": "Vorhandene Antworten bleiben im Kontingent",
"select_ending_card": "Abschlusskarte auswählen",
"upgrade_prompt_title": "Verwende Quoten mit einem höheren Plan",
"when_quota_has_been_reached": "Wenn das Kontingent erreicht ist"
},
"randomize_all": "Alle Optionen zufällig anordnen",
"randomize_all_except_last": "Alle Optionen zufällig anordnen außer der letzten",
"range": "Reichweite",
@@ -1567,6 +1613,7 @@
"url_not_supported": "URL nicht unterstützt",
"use_with_caution": "Mit Vorsicht verwenden",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
"variable_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
"verify_email_before_submission": "E-Mail vor dem Absenden überprüfen",
@@ -1601,11 +1648,14 @@
"address_line_2": "Adresszeile 2",
"an_error_occurred_deleting_the_tag": "Beim Löschen des Tags ist ein Fehler aufgetreten",
"browser": "Browser",
"bulk_delete_response_quotas": "Die Antworten sind Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
"city": "Stadt",
"company": "Firma",
"completed": "Erledigt ✅",
"country": "Land",
"decrement_quotas": "Alle Grenzwerte der Kontingente einschließlich dieser Antwort verringern",
"delete_response_confirmation": "Dies wird die Umfrageantwort einschließlich aller Antworten, Tags, angehängter Dokumente und Antwort-Metadaten löschen.",
"delete_response_quotas": "Die Antwort ist Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
"device": "Gerät",
"device_info": "Geräteinfo",
"email": "E-Mail",
@@ -1737,6 +1787,7 @@
"configure_alerts": "Benachrichtigungen konfigurieren",
"congrats": "Glückwunsch! Deine Umfrage ist jetzt live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
"current_count": "Aktuelle Anzahl",
"custom_range": "Benutzerdefinierter Bereich...",
"delete_all_existing_responses_and_displays": "Alle bestehenden Antworten und Anzeigen löschen",
"download_qr_code": "QR Code herunterladen",
@@ -1790,6 +1841,7 @@
"last_month": "Letztes Monat",
"last_quarter": "Letztes Quartal",
"last_year": "Letztes Jahr",
"limit": "Limit",
"no_responses_found": "Keine Antworten gefunden",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",
@@ -1798,6 +1850,8 @@
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
"qr_code_download_with_start_soon": "QR Code-Download startet bald",
"qr_code_generation_failed": "Es gab ein Problem beim Laden des QR-Codes für die Umfrage. Bitte versuchen Sie es erneut.",
"quotas_completed": "Kontingente abgeschlossen",
"quotas_completed_tooltip": "Die Anzahl der von den Befragten abgeschlossenen Quoten.",
"reset_survey": "Umfrage zurücksetzen",
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
"selected_responses_csv": "Ausgewählte Antworten (CSV)",

View File

@@ -169,11 +169,14 @@
"connect_formbricks": "Connect Formbricks",
"connected": "Connected",
"contacts": "Contacts",
"continue": "Continue",
"copied": "Copied",
"copied_to_clipboard": "Copied to clipboard",
"copy": "Copy",
"copy_code": "Copy code",
"copy_link": "Copy Link",
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
"create_new_organization": "Create new organization",
"create_project": "Create project",
"create_segment": "Create segment",
@@ -201,6 +204,7 @@
"e_commerce": "E-Commerce",
"edit": "Edit",
"email": "Email",
"ending_card": "Ending card",
"enterprise_license": "Enterprise License",
"environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.",
@@ -269,6 +273,7 @@
"no_background_image_found": "No background image found.",
"no_code": "No code",
"no_files_uploaded": "No files were uploaded",
"no_quotas_found": "No quotas found",
"no_result_found": "No result found",
"no_results": "No results",
"no_surveys_found": "No surveys found.",
@@ -312,6 +317,7 @@
"product_manager": "Product Manager",
"profile": "Profile",
"profile_id": "Profile ID",
"progress": "Progress",
"project_configuration": "Project Configuration",
"project_creation_description": "Organize surveys in projects for better access control.",
"project_id": "Project ID",
@@ -323,6 +329,9 @@
"question": "Question",
"question_id": "Question ID",
"questions": "Questions",
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
"read_docs": "Read Docs",
"recipients": "Recipients",
"remove": "Remove",
@@ -370,6 +379,7 @@
"start_free_trial": "Start Free Trial",
"status": "Status",
"step_by_step_manual": "Step by step manual",
"storage_not_configured": "File storage not set up, uploads will likely fail",
"styling": "Styling",
"submit": "Submit",
"summary": "Summary",
@@ -579,6 +589,7 @@
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts' data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
"no_responses_found": "No responses found",
"not_provided": "Not provided",
"search_contact": "Search contact",
@@ -739,7 +750,6 @@
},
"project": {
"api_keys": {
"access_control": "Access Control",
"add_api_key": "Add API Key",
"api_key": "API Key",
"api_key_copied_to_clipboard": "API key copied to clipboard",
@@ -1280,7 +1290,7 @@
"columns": "Columns",
"company": "Company",
"company_logo": "Company logo",
"completed_responses": "partial or completed responses.",
"completed_responses": "completed responses.",
"concat": "Concat +",
"conditional_logic": "Conditional Logic",
"confirm_default_language": "Confirm default language",
@@ -1320,6 +1330,7 @@
"end_screen_card": "End screen card",
"ending_card": "Ending card",
"ending_card_used_in_logic": "This ending card is used in logic of question {questionIndex}.",
"ending_used_in_quota": "This ending is being used in \"{quotaName}\" quota",
"ends_with": "Ends with",
"equals": "Equals",
"equals_one_of": "Equals one of",
@@ -1330,6 +1341,7 @@
"fallback_for": "Fallback for ",
"fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Hidden field \"{fieldId}\" is being used in \"{quotaName}\" quota",
"field_name_eg_score_price": "Field name e.g, score, price",
"first_name": "First Name",
"five_points_recommended": "5 points (recommended)",
@@ -1361,8 +1373,9 @@
"follow_ups_modal_action_subject_placeholder": "Subject of the email",
"follow_ups_modal_action_to_description": "Email address to send the email to",
"follow_ups_modal_action_to_label": "To",
"follow_ups_modal_action_to_warning": "No email field detected in the survey",
"follow_ups_modal_action_to_warning": "No valid options found for sending emails, please add some open-text / contact-info questions or hidden fields",
"follow_ups_modal_create_heading": "Create a new follow-up",
"follow_ups_modal_created_successfull_toast": "Follow-up created and will be saved once you save the survey.",
"follow_ups_modal_edit_heading": "Edit this follow-up",
"follow_ups_modal_edit_no_id": "No survey follow up id provided, can't update the survey follow up",
"follow_ups_modal_name_label": "Follow-up name",
@@ -1372,8 +1385,9 @@
"follow_ups_modal_trigger_label": "Trigger",
"follow_ups_modal_trigger_type_ending": "Respondent sees a specific ending",
"follow_ups_modal_trigger_type_ending_select": "Select endings: ",
"follow_ups_modal_trigger_type_ending_warning": "No endings found in the survey!",
"follow_ups_modal_trigger_type_ending_warning": "Please select at least one ending or change the trigger type",
"follow_ups_modal_trigger_type_response": "Respondent completes survey",
"follow_ups_modal_updated_successfull_toast": "Follow-up updated and will be saved once you save the survey.",
"follow_ups_new": "New follow-up",
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
"form_styling": "Form styling",
@@ -1474,6 +1488,38 @@
"question_duplicated": "Question duplicated.",
"question_id_updated": "Question ID updated",
"question_used_in_logic": "This question is used in logic of question {questionIndex}.",
"question_used_in_quota": "This question is being used in \"{quotaName}\" quota",
"quotas": {
"add_quota": "Add quota",
"change_quota_for_public_survey": "Change quota for public survey?",
"confirm_quota_changes": "Confirm quota changes",
"confirm_quota_changes_body": "You have unsaved changes in your quota. Would you like to save them before leaving?",
"continue_survey_normally": "Continue survey normally",
"count_partial_submissions": "Count partial submissions",
"count_partial_submissions_description": "Include respondents that match the quota criteria but did not complete the survey",
"create_quota_for_public_survey": "Create quota for public survey?",
"create_quota_for_public_survey_description": "Only future answers will be screened into quota",
"create_quota_for_public_survey_text": "This survey is already public. Existing responses will not be taken into account for the new quota.",
"delete_quota_confirmation_text": "This will permanently delete the quota {quotaName}.",
"duplicate_quota": "Duplicate quota",
"edit_quota": "Edit quota",
"end_survey_for_matching_participants": "End survey for matching participants",
"inclusion_criteria": "Inclusion Criteria",
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, one {You already have {value} response for this quota, so the limit must be greater than {value}.} other {You already have {value} responses for this quota, so the limit must be greater than {value}.}}",
"limited_to_x_responses": "Limited to {limit}",
"new_quota": "New Quota",
"quota_created_successfull_toast": "Quota created successfully",
"quota_deleted_successfull_toast": "Quota deleted successfully",
"quota_duplicated_successfull_toast": "Quota duplicated successfully",
"quota_name_placeholder": "e.g., Age 18-25 participants",
"quota_updated_successfull_toast": "Quota updated successfully",
"response_limit": "Limits",
"save_changes_confirmation_body": "Any changes to the inclusion criteria only affect future responses. \nWe recommend to either duplicate an existing or create a new quota.",
"save_changes_confirmation_text": "Existing responses stay in the quota",
"select_ending_card": "Select ending card",
"upgrade_prompt_title": "Use quotas with a higher plan",
"when_quota_has_been_reached": "When quota has been reached"
},
"randomize_all": "Randomize all",
"randomize_all_except_last": "Randomize all except last",
"range": "Range",
@@ -1567,6 +1613,7 @@
"url_not_supported": "URL not supported",
"use_with_caution": "Use with caution",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} is used in logic of question {questionIndex}. Please remove it from logic first.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
"variable_name_is_already_taken_please_choose_another": "Variable name is already taken, please choose another.",
"variable_name_must_start_with_a_letter": "Variable name must start with a letter.",
"verify_email_before_submission": "Verify email before submission",
@@ -1601,11 +1648,14 @@
"address_line_2": "Address Line 2",
"an_error_occurred_deleting_the_tag": "An error occurred deleting the tag",
"browser": "Browser",
"bulk_delete_response_quotas": "The responses are part of quotas for this survey. How do you want to handle the quotas?",
"city": "City",
"company": "Company",
"completed": "Completed ✅",
"country": "Country",
"decrement_quotas": "Decrement all limits of quotas including this response",
"delete_response_confirmation": "This will delete the survey response, including all answers, tags, attached documents, and response metadata.",
"delete_response_quotas": "The response is part of quotas for this survey. How do you want to handle the quotas?",
"device": "Device",
"device_info": "Device info",
"email": "Email",
@@ -1737,6 +1787,7 @@
"configure_alerts": "Configure alerts",
"congrats": "Congrats! Your survey is live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
"current_count": "Current count",
"custom_range": "Custom range...",
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
"download_qr_code": "Download QR code",
@@ -1790,6 +1841,7 @@
"last_month": "Last month",
"last_quarter": "Last quarter",
"last_year": "Last year",
"limit": "Limit",
"no_responses_found": "No responses found",
"other_values_found": "Other values found",
"overall": "Overall",
@@ -1798,6 +1850,8 @@
"qr_code_download_failed": "QR code download failed",
"qr_code_download_with_start_soon": "QR code download will start soon",
"qr_code_generation_failed": "There was a problem, loading the survey QR Code. Please try again.",
"quotas_completed": "Quotas completed",
"quotas_completed_tooltip": "The number of quotas completed by the respondents.",
"reset_survey": "Reset survey",
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
"selected_responses_csv": "Selected responses (CSV)",

View File

@@ -169,11 +169,14 @@
"connect_formbricks": "Connecter Formbricks",
"connected": "Connecté",
"contacts": "Contacts",
"continue": "Continuer",
"copied": "Copié",
"copied_to_clipboard": "Copié dans le presse-papiers",
"copy": "Copier",
"copy_code": "Copier le code",
"copy_link": "Copier le lien",
"count_contacts": "{value, plural, one {# contact} other {# contacts} }",
"count_responses": "{value, plural, other {# réponses}}",
"create_new_organization": "Créer une nouvelle organisation",
"create_project": "Créer un projet",
"create_segment": "Créer un segment",
@@ -201,6 +204,7 @@
"e_commerce": "E-commerce",
"edit": "Modifier",
"email": "Email",
"ending_card": "Carte de fin",
"enterprise_license": "Licence d'entreprise",
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
@@ -269,6 +273,7 @@
"no_background_image_found": "Aucune image de fond trouvée.",
"no_code": "Pas de code",
"no_files_uploaded": "Aucun fichier n'a été téléchargé.",
"no_quotas_found": "Aucun quota trouvé",
"no_result_found": "Aucun résultat trouvé",
"no_results": "Aucun résultat",
"no_surveys_found": "Aucun sondage trouvé.",
@@ -312,6 +317,7 @@
"product_manager": "Chef de produit",
"profile": "Profil",
"profile_id": "Identifiant de profil",
"progress": "Progression",
"project_configuration": "Configuration du projet",
"project_creation_description": "Organisez les enquêtes en projets pour un meilleur contrôle d'accès.",
"project_id": "ID de projet",
@@ -323,6 +329,9 @@
"question": "Question",
"question_id": "ID de la question",
"questions": "Questions",
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
"read_docs": "Lire les documents",
"recipients": "Destinataires",
"remove": "Retirer",
@@ -370,6 +379,7 @@
"start_free_trial": "Commencer l'essai gratuit",
"status": "Statut",
"step_by_step_manual": "Manuel étape par étape",
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
"styling": "Style",
"submit": "Soumettre",
"summary": "Résumé",
@@ -579,6 +589,7 @@
"contacts_table_refresh": "Rafraîchir les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
"delete_contact_confirmation_with_quotas": "{value, plural, other {Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus. Si ce contact a des réponses qui comptent dans les quotas de l'enquête, les comptes de quotas seront réduits mais les limites de quota resteront inchangées.}}",
"no_responses_found": "Aucune réponse trouvée",
"not_provided": "Non fourni",
"search_contact": "Rechercher un contact",
@@ -739,7 +750,6 @@
},
"project": {
"api_keys": {
"access_control": "Contrôle d'accès",
"add_api_key": "Ajouter une clé API",
"api_key": "Clé API",
"api_key_copied_to_clipboard": "Clé API copiée dans le presse-papiers",
@@ -1280,7 +1290,7 @@
"columns": "Colonnes",
"company": "Société",
"company_logo": "Logo de l'entreprise",
"completed_responses": "des réponses partielles ou complètes.",
"completed_responses": "Réponses terminées",
"concat": "Concat +",
"conditional_logic": "Logique conditionnelle",
"confirm_default_language": "Confirmer la langue par défaut",
@@ -1320,6 +1330,7 @@
"end_screen_card": "Carte de fin d'écran",
"ending_card": "Carte de fin",
"ending_card_used_in_logic": "Cette carte de fin est utilisée dans la logique de la question '{'questionIndex'}'.",
"ending_used_in_quota": "Cette fin est utilisée dans le quota \"{quotaName}\"",
"ends_with": "Se termine par",
"equals": "Égal",
"equals_one_of": "Égal à l'un de",
@@ -1330,6 +1341,7 @@
"fallback_for": "Solution de repli pour ",
"fallback_missing": "Fallback manquant",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Le champ masqué \"{fieldId}\" est utilisé dans le quota \"{quotaName}\"",
"field_name_eg_score_price": "Nom du champ par exemple, score, prix",
"first_name": "Prénom",
"five_points_recommended": "5 points (recommandé)",
@@ -1361,8 +1373,9 @@
"follow_ups_modal_action_subject_placeholder": "Objet de l'email",
"follow_ups_modal_action_to_description": "Adresse e-mail à laquelle envoyer l'e-mail",
"follow_ups_modal_action_to_label": "à",
"follow_ups_modal_action_to_warning": "Aucun champ d'email détecté dans l'enquête",
"follow_ups_modal_action_to_warning": "Aucune option valable trouvée pour l'envoi d'emails, veuillez ajouter des questions à texte libre / info-contact ou des champs cachés",
"follow_ups_modal_create_heading": "Créer un nouveau suivi",
"follow_ups_modal_created_successfull_toast": "\"Suivi créé et sera enregistré une fois que vous sauvegarderez le sondage.\"",
"follow_ups_modal_edit_heading": "Modifier ce suivi",
"follow_ups_modal_edit_no_id": "Aucun identifiant de suivi d'enquête fourni, impossible de mettre à jour le suivi de l'enquête.",
"follow_ups_modal_name_label": "Nom de suivi",
@@ -1372,8 +1385,9 @@
"follow_ups_modal_trigger_label": "Déclencheur",
"follow_ups_modal_trigger_type_ending": "Le répondant voit une fin spécifique",
"follow_ups_modal_trigger_type_ending_select": "Choisir des fins :",
"follow_ups_modal_trigger_type_ending_warning": "Aucune fin trouvée dans l'enquête !",
"follow_ups_modal_trigger_type_ending_warning": "Veuillez sélectionner au moins une fin ou changer le type de déclencheur.",
"follow_ups_modal_trigger_type_response": "Le répondant complète l'enquête",
"follow_ups_modal_updated_successfull_toast": "\"Suivi mis à jour et sera enregistré une fois que vous sauvegarderez le sondage.\"",
"follow_ups_new": "Nouveau suivi",
"follow_ups_upgrade_button_text": "Passez à la version supérieure pour activer les relances",
"form_styling": "Style de formulaire",
@@ -1474,6 +1488,38 @@
"question_duplicated": "Question dupliquée.",
"question_id_updated": "ID de la question mis à jour",
"question_used_in_logic": "Cette question est utilisée dans la logique de la question '{'questionIndex'}'.",
"question_used_in_quota": "Cette question est utilisée dans le quota \"{quotaName}\"",
"quotas": {
"add_quota": "Ajouter un quota",
"change_quota_for_public_survey": "Changer le quota pour le sondage public ?",
"confirm_quota_changes": "Confirmer les modifications de quotas",
"confirm_quota_changes_body": "Vous avez des modifications non enregistrées dans votre quota. Souhaitez-vous les enregistrer avant de partir ?",
"continue_survey_normally": "Continuer le sondage normalement",
"count_partial_submissions": "Compter les soumissions partielles",
"count_partial_submissions_description": "Inclure les répondants qui correspondent aux critères de quota mais n'ont pas terminé le sondage",
"create_quota_for_public_survey": "Créer un quota pour le sondage public ?",
"create_quota_for_public_survey_description": "Seules les réponses futures seront filtrées dans le quota",
"create_quota_for_public_survey_text": "Ce sondage est déjà public. Les réponses existantes ne seront pas prises en compte pour le nouveau quota.",
"delete_quota_confirmation_text": "Cela supprimera définitivement le quota {quotaName}.",
"duplicate_quota": "Dupliquer le quota",
"edit_quota": "Modifier le quota",
"end_survey_for_matching_participants": "Terminer l'enquête pour les participants correspondants",
"inclusion_criteria": "Critères d'inclusion",
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {La limite doit être supérieure ou égale au nombre de réponses}}",
"limited_to_x_responses": "Limité à {limit}",
"new_quota": "Nouveau Quota",
"quota_created_successfull_toast": "Quota créé avec succès",
"quota_deleted_successfull_toast": "Quota supprimé avec succès",
"quota_duplicated_successfull_toast": "Quota dupliqué avec succès",
"quota_name_placeholder": "par ex., Participants âgés de 18 à 25 ans",
"quota_updated_successfull_toast": "Quota mis à jour avec succès",
"response_limit": "Limites",
"save_changes_confirmation_body": "Les modifications apportées aux critères d'inclusion n'affectent que les réponses futures. \nNous vous recommandons soit de dupliquer un quota existant, soit d'en créer un nouveau.",
"save_changes_confirmation_text": "\"Les réponses existantes restent dans le quota\"",
"select_ending_card": "Sélectionner la carte de fin",
"upgrade_prompt_title": "Utilisez des quotas avec un plan supérieur",
"when_quota_has_been_reached": "Quand le quota est atteint"
},
"randomize_all": "Randomiser tout",
"randomize_all_except_last": "Randomiser tout sauf le dernier",
"range": "Plage",
@@ -1567,6 +1613,7 @@
"url_not_supported": "URL non supportée",
"use_with_caution": "À utiliser avec précaution",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
"variable_name_must_start_with_a_letter": "Le nom de la variable doit commencer par une lettre.",
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
@@ -1601,11 +1648,14 @@
"address_line_2": "Ligne d'adresse 2",
"an_error_occurred_deleting_the_tag": "Une erreur est survenue lors de la suppression de l'étiquette.",
"browser": "Navigateur",
"bulk_delete_response_quotas": "Les réponses font partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
"city": "Ville",
"company": "Société",
"completed": "Terminé ✅",
"country": "Pays",
"decrement_quotas": "Décrémentez toutes les limites des quotas y compris cette réponse",
"delete_response_confirmation": "Cela supprimera la réponse au sondage, y compris toutes les réponses, les étiquettes, les documents joints et les métadonnées de réponse.",
"delete_response_quotas": "La réponse fait partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
"device": "Dispositif",
"device_info": "Informations sur l'appareil",
"email": "Email",
@@ -1737,6 +1787,7 @@
"configure_alerts": "Configurer les alertes",
"congrats": "Félicitations ! Votre enquête est en ligne.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
"current_count": "Nombre actuel",
"custom_range": "Plage personnalisée...",
"delete_all_existing_responses_and_displays": "Supprimer toutes les réponses existantes et les affichages",
"download_qr_code": "Télécharger code QR",
@@ -1790,6 +1841,7 @@
"last_month": "Le mois dernier",
"last_quarter": "dernier trimestre",
"last_year": "l'année dernière",
"limit": "Limite",
"no_responses_found": "Aucune réponse trouvée",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
@@ -1798,6 +1850,8 @@
"qr_code_download_failed": "Échec du téléchargement du code QR",
"qr_code_download_with_start_soon": "Le téléchargement du code QR débutera bientôt",
"qr_code_generation_failed": "\"Un problème est survenu lors du chargement du code QR du sondage. Veuillez réessayer.\"",
"quotas_completed": "Quotas terminés",
"quotas_completed_tooltip": "Le nombre de quotas complétés par les répondants.",
"reset_survey": "Réinitialiser l'enquête",
"reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.",
"selected_responses_csv": "Réponses sélectionnées (CSV)",

View File

@@ -169,11 +169,14 @@
"connect_formbricks": "Formbricksを接続",
"connected": "接続済み",
"contacts": "連絡先",
"continue": "続行",
"copied": "コピーしました",
"copied_to_clipboard": "クリップボードにコピーしました",
"copy": "コピー",
"copy_code": "コードをコピー",
"copy_link": "リンクをコピー",
"count_contacts": "{count, plural, other {# 件の連絡先}}",
"count_responses": "{count, plural, other {# 件の回答}}",
"create_new_organization": "新しい組織を作成",
"create_project": "プロジェクトを作成",
"create_segment": "セグメントを作成",
@@ -201,6 +204,7 @@
"e_commerce": "Eコマース",
"edit": "編集",
"email": "メールアドレス",
"ending_card": "終了カード",
"enterprise_license": "エンタープライズライセンス",
"environment_not_found": "環境が見つかりません",
"environment_notice": "現在、{environment} 環境にいます。",
@@ -269,6 +273,7 @@
"no_background_image_found": "背景画像が見つかりません。",
"no_code": "ノーコード",
"no_files_uploaded": "ファイルがアップロードされていません",
"no_quotas_found": "クォータが見つかりません",
"no_result_found": "結果が見つかりません",
"no_results": "結果なし",
"no_surveys_found": "フォームが見つかりません。",
@@ -312,6 +317,7 @@
"product_manager": "プロダクトマネージャー",
"profile": "プロフィール",
"profile_id": "プロフィールID",
"progress": "進捗",
"project_configuration": "プロジェクト設定",
"project_creation_description": "より良いアクセス制御のために、フォームをプロジェクトで整理します。",
"project_id": "プロジェクトID",
@@ -323,6 +329,9 @@
"question": "質問",
"question_id": "質問ID",
"questions": "質問",
"quota": "クォータ",
"quotas": "クォータ",
"quotas_description": "特定の基準を満たす参加者からの回答数を制限する",
"read_docs": "ドキュメントを読む",
"recipients": "受信者",
"remove": "削除",
@@ -370,6 +379,7 @@
"start_free_trial": "無料トライアルを開始",
"status": "ステータス",
"step_by_step_manual": "ステップバイステップマニュアル",
"storage_not_configured": "ファイルストレージが設定されていないため、アップロードは失敗する可能性があります",
"styling": "スタイル",
"submit": "送信",
"summary": "概要",
@@ -579,6 +589,7 @@
"contacts_table_refresh": "連絡先を更新",
"contacts_table_refresh_success": "連絡先を正常に更新しました",
"delete_contact_confirmation": "これにより、この連絡先に関連付けられているすべてのフォーム回答と連絡先属性が削除されます。この連絡先のデータに基づいたターゲティングとパーソナライゼーションはすべて失われます。",
"delete_contact_confirmation_with_quotas": "{value, plural, one {これにより この連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。この連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。この連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。} other {これにより これらの連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。これらの連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。これらの連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。}}",
"no_responses_found": "回答が見つかりません",
"not_provided": "提供されていません",
"search_contact": "連絡先を検索",
@@ -739,7 +750,6 @@
},
"project": {
"api_keys": {
"access_control": "アクセス制御",
"add_api_key": "APIキーを追加",
"api_key": "APIキー",
"api_key_copied_to_clipboard": "APIキーをクリップボードにコピーしました",
@@ -1280,7 +1290,7 @@
"columns": "列",
"company": "会社",
"company_logo": "会社のロゴ",
"completed_responses": "部分的または完了した回答",
"completed_responses": "完了した回答",
"concat": "連結 +",
"conditional_logic": "条件付きロジック",
"confirm_default_language": "デフォルト言語を確認",
@@ -1320,6 +1330,7 @@
"end_screen_card": "終了画面カード",
"ending_card": "終了カード",
"ending_card_used_in_logic": "この終了カードは質問 {questionIndex} のロジックで使用されています。",
"ending_used_in_quota": "この 終了 は \"{quotaName}\" クォータ で使用されています",
"ends_with": "で終わる",
"equals": "と等しい",
"equals_one_of": "のいずれかと等しい",
@@ -1330,6 +1341,7 @@
"fallback_for": "のフォールバック",
"fallback_missing": "フォールバックがありません",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隠しフィールド \"{fieldId}\" は \"{quotaName}\" クォータ で使用されています",
"field_name_eg_score_price": "フィールド名、例score、price",
"first_name": "名",
"five_points_recommended": "5点推奨",
@@ -1361,8 +1373,9 @@
"follow_ups_modal_action_subject_placeholder": "メールの件名",
"follow_ups_modal_action_to_description": "メールを送信するメールアドレス",
"follow_ups_modal_action_to_label": "宛先",
"follow_ups_modal_action_to_warning": "フォームでメールアドレスのフィールドが検出されていません",
"follow_ups_modal_action_to_warning": "メールを送信するための有効な オプション が見つかりません 、いくつかの オープン テキスト / 連絡先 情報の質問 または 非表示 フィールドを追加してください",
"follow_ups_modal_create_heading": "新しいフォローアップを作成",
"follow_ups_modal_created_successfull_toast": "フォローアップ が 作成され、 アンケートを 保存すると保存されます。",
"follow_ups_modal_edit_heading": "このフォローアップを編集",
"follow_ups_modal_edit_no_id": "フォームのフォローアップIDが提供されていません。フォームのフォローアップを更新できません",
"follow_ups_modal_name_label": "フォローアップ名",
@@ -1372,8 +1385,9 @@
"follow_ups_modal_trigger_label": "トリガー",
"follow_ups_modal_trigger_type_ending": "回答者が特定の終了画面を見たとき",
"follow_ups_modal_trigger_type_ending_select": "終了を選択:",
"follow_ups_modal_trigger_type_ending_warning": "フォームに終了画面が見つかりません!",
"follow_ups_modal_trigger_type_ending_warning": "少なくとも1つの終了を選択するか、 トリガー タイプを変更してください",
"follow_ups_modal_trigger_type_response": "回答者がフォームを完了したとき",
"follow_ups_modal_updated_successfull_toast": "フォローアップ が 更新され、 アンケートを 保存すると保存されます。",
"follow_ups_new": "新しいフォローアップ",
"follow_ups_upgrade_button_text": "フォローアップを有効にするためにアップグレード",
"form_styling": "フォームのスタイル",
@@ -1474,6 +1488,38 @@
"question_duplicated": "質問を複製しました。",
"question_id_updated": "質問IDを更新しました",
"question_used_in_logic": "この質問は質問 {questionIndex} のロジックで使用されています。",
"question_used_in_quota": "この 質問 は \"{quotaName}\" の クオータ に使用されています",
"quotas": {
"add_quota": "クォータを追加",
"change_quota_for_public_survey": "パブリック フォームのクォータを変更しますか?",
"confirm_quota_changes": "配分の変更を確認",
"confirm_quota_changes_body": "クォータに未保存の変更があります。離れる前に保存しますか?",
"continue_survey_normally": "アンケートを通常通り続行",
"count_partial_submissions": "部分的な提出の数を数える",
"count_partial_submissions_description": "クォータ基準を満たしているものの、調査を完了しなかった回答者を含める",
"create_quota_for_public_survey": "パブリック フォームのクォータを作成しますか?",
"create_quota_for_public_survey_description": "今後の回答のみがクォータにスクリーニングされます",
"create_quota_for_public_survey_text": "この調査はすでに公開されています。既存の回答は、新しい割当には考慮されません。",
"delete_quota_confirmation_text": "これは永久にクォータ {quotaName} を削除します。",
"duplicate_quota": "割り当ての複製",
"edit_quota": "クオータを編集",
"end_survey_for_matching_participants": "一致する参加者に対してアンケートを終了",
"inclusion_criteria": "選定基準",
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other { この クオータ では すでに {value} 件 の回答があります ので、制限は {value} より大きくする必要があります。} }",
"limited_to_x_responses": "{limit} 回に制限",
"new_quota": "新しい クォータ",
"quota_created_successfull_toast": "クオータを正常に作成しました",
"quota_deleted_successfull_toast": "クオータを正常に削除しました",
"quota_duplicated_successfull_toast": "クオータを正常に複製しました",
"quota_name_placeholder": "例: 年齢 18 から 25 歳 の 参加者",
"quota_updated_successfull_toast": "クオータを更新しました",
"response_limit": "制限",
"save_changes_confirmation_body": "今後の回答のみに影響します。\\n 既存のクォータを複製するか、新しいクォータを作成することをお勧めします。",
"save_changes_confirmation_text": "既存の応答 は クォータ に とどまります",
"select_ending_card": "終了カードを選択",
"upgrade_prompt_title": "上位プランで クォータ を使用",
"when_quota_has_been_reached": "クオータが達成されたとき"
},
"randomize_all": "すべてをランダム化",
"randomize_all_except_last": "最後を除くすべてをランダム化",
"range": "範囲",
@@ -1567,6 +1613,7 @@
"url_not_supported": "URLはサポートされていません",
"use_with_caution": "注意して使用",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
"variable_name_must_start_with_a_letter": "変数名はアルファベットで始まらなければなりません。",
"verify_email_before_submission": "送信前にメールアドレスを認証",
@@ -1601,11 +1648,14 @@
"address_line_2": "住所2",
"an_error_occurred_deleting_the_tag": "タグの削除中にエラーが発生しました",
"browser": "ブラウザ",
"bulk_delete_response_quotas": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?",
"city": "市区町村",
"company": "会社",
"completed": "完了 ✅",
"country": "国",
"decrement_quotas": "すべて の 制限 を 減少 し、 この 回答 を 含む しきい値",
"delete_response_confirmation": "これにより、すべての回答、タグ、添付されたドキュメント、および回答メタデータを含むフォームの回答が削除されます。",
"delete_response_quotas": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?",
"device": "デバイス",
"device_info": "デバイス情報",
"email": "メールアドレス",
@@ -1737,6 +1787,7 @@
"configure_alerts": "アラートを設定",
"congrats": "おめでとうございます!フォームが公開されました。",
"connect_your_website_or_app_with_formbricks_to_get_started": "始めるには、ウェブサイトやアプリをFormbricksに接続してください。",
"current_count": "現在の件数",
"custom_range": "カスタム範囲...",
"delete_all_existing_responses_and_displays": "既存のすべての回答と表示を削除",
"download_qr_code": "QRコードをダウンロード",
@@ -1790,6 +1841,7 @@
"last_month": "先月",
"last_quarter": "前四半期",
"last_year": "昨年",
"limit": "制限",
"no_responses_found": "回答が見つかりません",
"other_values_found": "他の値が見つかりました",
"overall": "全体",
@@ -1798,6 +1850,8 @@
"qr_code_download_failed": "QRコードのダウンロードに失敗しました",
"qr_code_download_with_start_soon": "QRコードのダウンロードがまもなく開始されます",
"qr_code_generation_failed": "フォームのQRコードの読み込み中に問題が発生しました。もう一度お試しください。",
"quotas_completed": "クォータ完了",
"quotas_completed_tooltip": "回答者 によって 完了 した 定員 の 数。",
"reset_survey": "フォームをリセット",
"reset_survey_warning": "フォームをリセットすると、このフォームに関連付けられているすべての回答と表示が削除されます。この操作は元に戻せません。",
"selected_responses_csv": "選択した回答 (CSV)",

View File

@@ -169,11 +169,14 @@
"connect_formbricks": "Conectar Formbricks",
"connected": "conectado",
"contacts": "Contatos",
"continue": "Continuar",
"copied": "Copiado",
"copied_to_clipboard": "Copiado para a área de transferência",
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"count_contacts": "{value, plural, one {# contato} other {# contatos} }",
"count_responses": "{value, plural, other {# respostas}}",
"create_new_organization": "Criar nova organização",
"create_project": "Criar projeto",
"create_segment": "Criar segmento",
@@ -201,6 +204,7 @@
"e_commerce": "comércio eletrônico",
"edit": "Editar",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enterprise_license": "Licença Empresarial",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
@@ -269,6 +273,7 @@
"no_background_image_found": "Imagem de fundo não encontrada.",
"no_code": "Sem código",
"no_files_uploaded": "Nenhum arquivo foi enviado",
"no_quotas_found": "Nenhuma cota encontrada",
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
"no_surveys_found": "Não foram encontradas pesquisas.",
@@ -312,6 +317,7 @@
"product_manager": "Gerente de Produto",
"profile": "Perfil",
"profile_id": "ID de Perfil",
"progress": "Progresso",
"project_configuration": "Configuração do Projeto",
"project_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
"project_id": "ID do Projeto",
@@ -323,6 +329,9 @@
"question": "Pergunta",
"question_id": "ID da Pergunta",
"questions": "Perguntas",
"quota": "Cota",
"quotas": "Cotas",
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
"read_docs": "Ler Documentação",
"recipients": "Destinatários",
"remove": "remover",
@@ -370,6 +379,7 @@
"start_free_trial": "Iniciar Teste Grátis",
"status": "status",
"step_by_step_manual": "Manual passo a passo",
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
"styling": "Estilização",
"submit": "Enviar",
"summary": "Resumo",
@@ -579,6 +589,7 @@
"contacts_table_refresh": "Atualizar contatos",
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos. Se este contato tiver respostas que contam para cotas da pesquisa, as contagens das cotas serão reduzidas, mas os limites das cotas permanecerão inalterados.}}",
"no_responses_found": "Nenhuma resposta encontrada",
"not_provided": "Não fornecido",
"search_contact": "Buscar contato",
@@ -739,7 +750,6 @@
},
"project": {
"api_keys": {
"access_control": "Controle de Acesso",
"add_api_key": "Adicionar Chave API",
"api_key": "Chave de API",
"api_key_copied_to_clipboard": "Chave da API copiada para a área de transferência",
@@ -1280,7 +1290,7 @@
"columns": "colunas",
"company": "empresa",
"company_logo": "Logo da empresa",
"completed_responses": "respostas parciais ou completas.",
"completed_responses": "Respostas concluídas.",
"concat": "Concatenar +",
"conditional_logic": "Lógica Condicional",
"confirm_default_language": "Confirmar idioma padrão",
@@ -1320,6 +1330,7 @@
"end_screen_card": "cartão de tela final",
"ending_card": "Cartão de encerramento",
"ending_card_used_in_logic": "Esse cartão de encerramento é usado na lógica da pergunta {questionIndex}.",
"ending_used_in_quota": "Este final está sendo usado na cota \"{quotaName}\"",
"ends_with": "Termina com",
"equals": "Igual",
"equals_one_of": "É igual a um de",
@@ -1330,6 +1341,7 @@
"fallback_for": "Alternativa para",
"fallback_missing": "Faltando alternativa",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está sendo usado na cota \"{quotaName}\"",
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
"first_name": "Primeiro Nome",
"five_points_recommended": "5 pontos (recomendado)",
@@ -1361,8 +1373,9 @@
"follow_ups_modal_action_subject_placeholder": "Assunto do e-mail",
"follow_ups_modal_action_to_description": "Endereço de e-mail para enviar o e-mail para",
"follow_ups_modal_action_to_label": "Para",
"follow_ups_modal_action_to_warning": "Nenhum campo de e-mail detectado na pesquisa",
"follow_ups_modal_action_to_warning": "Nenhuma opção válida encontrada para envio de emails, por favor, adicione algumas perguntas de texto livre / informações de contato ou campos ocultos",
"follow_ups_modal_create_heading": "Criar um novo acompanhamento",
"follow_ups_modal_created_successfull_toast": "Acompanhamento criado e será salvo assim que você salvar a pesquisa.",
"follow_ups_modal_edit_heading": "Editar este acompanhamento",
"follow_ups_modal_edit_no_id": "Nenhum ID de acompanhamento da pesquisa fornecido, não é possível atualizar o acompanhamento da pesquisa",
"follow_ups_modal_name_label": "Nome do acompanhamento",
@@ -1372,8 +1385,9 @@
"follow_ups_modal_trigger_label": "Gatilho",
"follow_ups_modal_trigger_type_ending": "Respondente vê um final específico",
"follow_ups_modal_trigger_type_ending_select": "Selecione os finais: ",
"follow_ups_modal_trigger_type_ending_warning": "Nenhum final encontrado na pesquisa!",
"follow_ups_modal_trigger_type_ending_warning": "Por favor, selecione pelo menos um encerramento ou altere o tipo de gatilho",
"follow_ups_modal_trigger_type_response": "Respondente completa a pesquisa",
"follow_ups_modal_updated_successfull_toast": "Acompanhamento atualizado e será salvo assim que você salvar a pesquisa.",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos",
"form_styling": "Estilização de Formulários",
@@ -1474,6 +1488,38 @@
"question_duplicated": "Pergunta duplicada.",
"question_id_updated": "ID da pergunta atualizado",
"question_used_in_logic": "Essa pergunta é usada na lógica da pergunta {questionIndex}.",
"question_used_in_quota": "Esta questão está sendo usada na cota \"{quotaName}\"",
"quotas": {
"add_quota": "Adicionar cota",
"change_quota_for_public_survey": "Alterar cota para pesquisa pública?",
"confirm_quota_changes": "Confirmar Alterações nas Cotas",
"confirm_quota_changes_body": "Você tem alterações não salvas na sua cota. Quer salvar antes de sair?",
"continue_survey_normally": "Continuar pesquisa normalmente",
"count_partial_submissions": "Contar respostas parciais",
"count_partial_submissions_description": "Incluir respondentes que atendem aos critérios de cota, mas não completaram a pesquisa",
"create_quota_for_public_survey": "Criar cota para pesquisa pública?",
"create_quota_for_public_survey_description": "Apenas respostas futuras serão filtradas para a cota",
"create_quota_for_public_survey_text": "Esta pesquisa já é pública. Respostas existentes não serão consideradas para a nova cota.",
"delete_quota_confirmation_text": "Isso irá apagar permanentemente a cota {quotaName}.",
"duplicate_quota": "Duplicar cota",
"edit_quota": "Editar cota",
"end_survey_for_matching_participants": "Encerrar a pesquisa para participantes correspondentes",
"inclusion_criteria": "Critérios de Inclusão",
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {O limite deve ser maior ou igual ao número de respostas}}",
"limited_to_x_responses": "Limitado a {limit}",
"new_quota": "Nova Cota",
"quota_created_successfull_toast": "Cota criada com sucesso",
"quota_deleted_successfull_toast": "Cota deletada com sucesso",
"quota_duplicated_successfull_toast": "Cota duplicada com sucesso",
"quota_name_placeholder": "ex.: Participantes de 18-25 anos",
"quota_updated_successfull_toast": "Cota atualizada com sucesso",
"response_limit": "Limites",
"save_changes_confirmation_body": "Quaisquer alterações nos critérios de inclusão afetam apenas respostas futuras. \nRecomendamos duplicar uma cota existente ou criar uma nova.",
"save_changes_confirmation_text": "Respostas existentes permanecem na cota",
"select_ending_card": "Selecione cartão de final",
"upgrade_prompt_title": "Use cotas com um plano superior",
"when_quota_has_been_reached": "Quando a cota for atingida"
},
"randomize_all": "Randomizar tudo",
"randomize_all_except_last": "Randomizar tudo, exceto o último",
"range": "alcance",
@@ -1567,6 +1613,7 @@
"url_not_supported": "URL não suportada",
"use_with_caution": "Use com cuidado",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} está sendo usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
@@ -1601,11 +1648,14 @@
"address_line_2": "Complemento",
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao deletar a tag",
"browser": "navegador",
"bulk_delete_response_quotas": "As respostas fazem parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
"city": "Cidade",
"company": "empresa",
"completed": "Concluído ✅",
"country": "País",
"decrement_quotas": "Diminua todos os limites de cotas, incluindo esta resposta",
"delete_response_confirmation": "Isso irá excluir a resposta da pesquisa, incluindo todas as respostas, etiquetas, documentos anexados e metadados da resposta.",
"delete_response_quotas": "A resposta faz parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
"device": "dispositivo",
"device_info": "Informações do dispositivo",
"email": "Email",
@@ -1737,6 +1787,7 @@
"configure_alerts": "Configurar alertas",
"congrats": "Parabéns! Sua pesquisa está no ar.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
"current_count": "Contagem Atual",
"custom_range": "Intervalo personalizado...",
"delete_all_existing_responses_and_displays": "Excluir todas as respostas e exibições existentes",
"download_qr_code": "baixar código QR",
@@ -1790,6 +1841,7 @@
"last_month": "Último mês",
"last_quarter": "Último trimestre",
"last_year": "Último ano",
"limit": "Limite",
"no_responses_found": "Nenhuma resposta encontrada",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
@@ -1798,6 +1850,8 @@
"qr_code_download_failed": "falha no download do código QR",
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
"qr_code_generation_failed": "Houve um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
"quotas_completed": "Cotas concluídas",
"quotas_completed_tooltip": "Número de cotas preenchidas pelos respondentes.",
"reset_survey": "Redefinir pesquisa",
"reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.",
"selected_responses_csv": "Respostas selecionadas (CSV)",

View File

@@ -169,11 +169,14 @@
"connect_formbricks": "Ligar Formbricks",
"connected": "Conectado",
"contacts": "Contactos",
"continue": "Continuar",
"copied": "Copiado",
"copied_to_clipboard": "Copiado para a área de transferência",
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"count_contacts": "{value, plural, one {# contacto} other {# contactos} }",
"count_responses": "{value, plural, other {# respostas}}",
"create_new_organization": "Criar nova organização",
"create_project": "Criar projeto",
"create_segment": "Criar segmento",
@@ -201,6 +204,7 @@
"e_commerce": "Comércio Eletrónico",
"edit": "Editar",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enterprise_license": "Licença Enterprise",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
@@ -269,6 +273,7 @@
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
"no_code": "Sem código",
"no_files_uploaded": "Nenhum ficheiro foi carregado",
"no_quotas_found": "Nenhum quota encontrado",
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
"no_surveys_found": "Nenhum inquérito encontrado.",
@@ -312,6 +317,7 @@
"product_manager": "Gestor de Produto",
"profile": "Perfil",
"profile_id": "ID do Perfil",
"progress": "Progresso",
"project_configuration": "Configuração do Projeto",
"project_creation_description": "Organize questionários em projetos para um melhor controlo de acesso.",
"project_id": "ID do Projeto",
@@ -323,6 +329,9 @@
"question": "Pergunta",
"question_id": "ID da pergunta",
"questions": "Perguntas",
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
"read_docs": "Ler Documentos",
"recipients": "Destinatários",
"remove": "Remover",
@@ -370,6 +379,7 @@
"start_free_trial": "Iniciar Teste Grátis",
"status": "Estado",
"step_by_step_manual": "Manual passo a passo",
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
"styling": "Estilo",
"submit": "Submeter",
"summary": "Resumo",
@@ -579,6 +589,7 @@
"contacts_table_refresh": "Atualizar contactos",
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isto irá eliminar todas as respostas das pesquisas e os atributos de contacto associados a este contacto. Qualquer segmentação e personalização baseados nos dados deste contacto serão perdidos. Se este contacto tiver respostas que contribuam para as quotas das pesquisas, as contagens de quotas serão reduzidas, mas os limites das quotas permanecerão inalterados.}}",
"no_responses_found": "Nenhuma resposta encontrada",
"not_provided": "Não fornecido",
"search_contact": "Procurar contacto",
@@ -739,7 +750,6 @@
},
"project": {
"api_keys": {
"access_control": "Controlo de Acesso",
"add_api_key": "Adicionar Chave API",
"api_key": "Chave API",
"api_key_copied_to_clipboard": "Chave API copiada para a área de transferência",
@@ -1280,7 +1290,7 @@
"columns": "Colunas",
"company": "Empresa",
"company_logo": "Logotipo da empresa",
"completed_responses": "respostas parciais ou completas",
"completed_responses": "Respostas concluídas",
"concat": "Concatenar +",
"conditional_logic": "Lógica Condicional",
"confirm_default_language": "Confirmar idioma padrão",
@@ -1320,6 +1330,7 @@
"end_screen_card": "Cartão de ecrã final",
"ending_card": "Cartão de encerramento",
"ending_card_used_in_logic": "Este cartão final é usado na lógica da pergunta {questionIndex}.",
"ending_used_in_quota": "Este final está a ser usado na quota \"{quotaName}\"",
"ends_with": "Termina com",
"equals": "Igual",
"equals_one_of": "Igual a um de",
@@ -1330,6 +1341,7 @@
"fallback_for": "Alternativa para ",
"fallback_missing": "Substituição em falta",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está a ser usado na quota \"{quotaName}\"",
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
"first_name": "Primeiro Nome",
"five_points_recommended": "5 pontos (recomendado)",
@@ -1361,8 +1373,9 @@
"follow_ups_modal_action_subject_placeholder": "Assunto do email",
"follow_ups_modal_action_to_description": "Endereço de email para enviar o email",
"follow_ups_modal_action_to_label": "Para",
"follow_ups_modal_action_to_warning": "Nenhum campo de email detetado no inquérito",
"follow_ups_modal_action_to_warning": "Não foram encontradas opções válidas para envio de emails, por favor adicione algumas perguntas de texto livre / informações de contato ou campos escondidos",
"follow_ups_modal_create_heading": "Criar um novo acompanhamento",
"follow_ups_modal_created_successfull_toast": "Seguimento criado e será guardado assim que guardar o questionário.",
"follow_ups_modal_edit_heading": "Editar este acompanhamento",
"follow_ups_modal_edit_no_id": "Nenhum ID de acompanhamento do inquérito fornecido, não é possível atualizar o acompanhamento do inquérito",
"follow_ups_modal_name_label": "Nome do acompanhamento",
@@ -1372,8 +1385,9 @@
"follow_ups_modal_trigger_label": "Desencadeador",
"follow_ups_modal_trigger_type_ending": "O respondente vê um final específico",
"follow_ups_modal_trigger_type_ending_select": "Selecionar finais: ",
"follow_ups_modal_trigger_type_ending_warning": "Não foram encontrados finais no inquérito!",
"follow_ups_modal_trigger_type_ending_warning": "Por favor, selecione pelo menos um final ou mude o tipo de gatilho",
"follow_ups_modal_trigger_type_response": "Respondente conclui inquérito",
"follow_ups_modal_updated_successfull_toast": "Seguimento atualizado e será guardado assim que guardar o questionário.",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para ativar os acompanhamentos",
"form_styling": "Estilo do formulário",
@@ -1474,6 +1488,38 @@
"question_duplicated": "Pergunta duplicada.",
"question_id_updated": "ID da pergunta atualizado",
"question_used_in_logic": "Esta pergunta é usada na lógica da pergunta {questionIndex}.",
"question_used_in_quota": "Esta pergunta está a ser usada na quota \"{quotaName}\"",
"quotas": {
"add_quota": "Adicionar quota",
"change_quota_for_public_survey": "Alterar quota para inquérito público?",
"confirm_quota_changes": "Confirmar Alterações das Quotas",
"confirm_quota_changes_body": "Tem alterações não guardadas na sua cota. Gostaria de as guardar antes de sair?",
"continue_survey_normally": "Continua a pesquisa normalmente",
"count_partial_submissions": "Contar submissões parciais",
"count_partial_submissions_description": "Incluir respondentes que correspondem aos critérios de quota mas não completaram o inquérito",
"create_quota_for_public_survey": "Criar quota para inquérito público?",
"create_quota_for_public_survey_description": "Apenas respostas futuras serão controladas no limite",
"create_quota_for_public_survey_text": "Este questionário já é público. As respostas existentes não serão consideradas na nova quota.",
"delete_quota_confirmation_text": "Isto irá apagar permanentemente a quota {quotaName}.",
"duplicate_quota": "Duplicar quota",
"edit_quota": "Editar cota",
"end_survey_for_matching_participants": "Encerrar inquérito para participantes correspondentes",
"inclusion_criteria": "Critérios de Inclusão",
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {Limite deve ser maior ou igual ao número de respostas}}",
"limited_to_x_responses": "Limitado a {limit}",
"new_quota": "Nova Cota",
"quota_created_successfull_toast": "Quota criada com sucesso",
"quota_deleted_successfull_toast": "Quota eliminada com sucesso",
"quota_duplicated_successfull_toast": "Quota duplicada com sucesso",
"quota_name_placeholder": "por exemplo, Participantes Idade 18-25",
"quota_updated_successfull_toast": "Quota atualizada com sucesso",
"response_limit": "Limites",
"save_changes_confirmation_body": "Quaisquer alterações aos critérios de inclusão afetam apenas respostas futuras. \nRecomendamos duplicar uma cota existente ou criar uma nova.",
"save_changes_confirmation_text": "As respostas existentes permanecem na cota",
"select_ending_card": "Selecionar cartão de encerramento",
"upgrade_prompt_title": "Utilize quotas com um plano superior",
"when_quota_has_been_reached": "Quando a quota foi atingida"
},
"randomize_all": "Aleatorizar todos",
"randomize_all_except_last": "Aleatorizar todos exceto o último",
"range": "Intervalo",
@@ -1567,6 +1613,7 @@
"url_not_supported": "URL não suportado",
"use_with_caution": "Usar com cautela",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
"verify_email_before_submission": "Verificar email antes da submissão",
@@ -1601,11 +1648,14 @@
"address_line_2": "Endereço Linha 2",
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao eliminar a etiqueta",
"browser": "Navegador",
"bulk_delete_response_quotas": "As respostas são parte das quotas deste inquérito. Como deseja gerir as quotas?",
"city": "Cidade",
"company": "Empresa",
"completed": "Concluído ✅",
"country": "País",
"decrement_quotas": "Decrementar todos os limites das cotas incluindo esta resposta",
"delete_response_confirmation": "Isto irá apagar a resposta do inquérito, incluindo todas as respostas, etiquetas, documentos anexos e metadados da resposta.",
"delete_response_quotas": "A resposta faz parte das quotas deste inquérito. Como deseja gerir as quotas?",
"device": "Dispositivo",
"device_info": "Informações do dispositivo",
"email": "Email",
@@ -1737,6 +1787,7 @@
"configure_alerts": "Configurar alertas",
"congrats": "Parabéns! O seu inquérito está ativo.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
"current_count": "Contagem atual",
"custom_range": "Intervalo personalizado...",
"delete_all_existing_responses_and_displays": "Excluir todas as respostas existentes e exibições",
"download_qr_code": "Transferir código QR",
@@ -1790,6 +1841,7 @@
"last_month": "Último mês",
"last_quarter": "Último trimestre",
"last_year": "Ano passado",
"limit": "Limite",
"no_responses_found": "Nenhuma resposta encontrada",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
@@ -1798,6 +1850,8 @@
"qr_code_download_failed": "Falha ao transferir o código QR",
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
"qr_code_generation_failed": "Ocorreu um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
"quotas_completed": "Quotas concluídas",
"quotas_completed_tooltip": "O número de quotas concluídas pelos respondentes.",
"reset_survey": "Reiniciar inquérito",
"reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.",
"selected_responses_csv": "Respostas selecionadas (CSV)",

View File

@@ -169,11 +169,14 @@
"connect_formbricks": "Conectează Formbricks",
"connected": "Conectat",
"contacts": "Contacte",
"continue": "Continuă",
"copied": "Copiat",
"copied_to_clipboard": "Copiat în clipboard",
"copy": "Copiază",
"copy_code": "Copiază codul",
"copy_link": "Copiază legătura",
"count_contacts": "{value, plural, one {# contact} other {# contacte} }",
"count_responses": "{value, plural, one {# răspuns} other {# răspunsuri} }",
"create_new_organization": "Creează organizație nouă",
"create_project": "Creează proiect",
"create_segment": "Creați segment",
@@ -201,6 +204,7 @@
"e_commerce": "Comerț electronic",
"edit": "Editare",
"email": "Email",
"ending_card": "Cardul de finalizare",
"enterprise_license": "Licență Întreprindere",
"environment_not_found": "Mediul nu a fost găsit",
"environment_notice": "Te afli în prezent în mediul {environment}",
@@ -269,6 +273,7 @@
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
"no_code": "Fără Cod",
"no_files_uploaded": "Nu au fost încărcate fișiere",
"no_quotas_found": "Nicio cotă găsită",
"no_result_found": "Niciun rezultat găsit",
"no_results": "Nicio rezultat",
"no_surveys_found": "Nu au fost găsite sondaje.",
@@ -312,6 +317,7 @@
"product_manager": "Manager de Produs",
"profile": "Profil",
"profile_id": "ID Profil",
"progress": "Progres",
"project_configuration": "Configurare proiect",
"project_creation_description": "Organizați sondajele în proiecte pentru un control mai bun al accesului.",
"project_id": "ID proiect",
@@ -323,6 +329,9 @@
"question": "Întrebare",
"question_id": "ID întrebare",
"questions": "Întrebări",
"quota": "Cotă",
"quotas": "Cote",
"quotas_description": "Limitați numărul de răspunsuri primite de la participanții care îndeplinesc anumite criterii.",
"read_docs": "Citește documentația",
"recipients": "Destinatari",
"remove": "Șterge",
@@ -370,6 +379,7 @@
"start_free_trial": "Începe perioada de testare gratuită",
"status": "Stare",
"step_by_step_manual": "Manual pas cu pas",
"storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil",
"styling": "Stilizare",
"submit": "Trimite",
"summary": "Sumar",
@@ -579,6 +589,7 @@
"contacts_table_refresh": "Reîmprospătare contacte",
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
"delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {Această acțiune va șterge toate răspunsurile chestionarului și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute. Dacă acest contact are răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} other {Aceste acțiuni vor șterge toate răspunsurile chestionarului și atributele de contact asociate cu acești contacți. Orice țintire și personalizare bazată pe datele acestor contacți vor fi pierdute. Dacă acești contacți au răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} }",
"no_responses_found": "Nu s-au găsit răspunsuri",
"not_provided": "Nu a fost furnizat",
"search_contact": "Căutați contact",
@@ -739,7 +750,6 @@
},
"project": {
"api_keys": {
"access_control": "Control acces",
"add_api_key": "Adaugă Cheie API",
"api_key": "Cheie API",
"api_key_copied_to_clipboard": "Cheia API a fost copiată în clipboard",
@@ -1280,7 +1290,7 @@
"columns": "Coloane",
"company": "Companie",
"company_logo": "Sigla companiei",
"completed_responses": "răspunsuri parțiale sau finalizate",
"completed_responses": "Răspunsuri completate",
"concat": "Concat +",
"conditional_logic": "Logică condițională",
"confirm_default_language": "Confirmați limba implicită",
@@ -1320,6 +1330,7 @@
"end_screen_card": "Ecran final card",
"ending_card": "Cardul de finalizare",
"ending_card_used_in_logic": "Această carte de încheiere este folosită în logica întrebării {questionIndex}.",
"ending_used_in_quota": "Finalul acesta este folosit în cota \"{quotaName}\"",
"ends_with": "Se termină cu",
"equals": "Egal",
"equals_one_of": "Egal unu dintre",
@@ -1330,6 +1341,7 @@
"fallback_for": "Varianta de rezervă pentru",
"fallback_missing": "Rezerva lipsă",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Câmpul ascuns \"{fieldId}\" este folosit în cota \"{quotaName}\"",
"field_name_eg_score_price": "Nume câmp, de exemplu, scor, preț",
"first_name": "Prenume",
"five_points_recommended": "5 puncte (recomandat)",
@@ -1361,8 +1373,9 @@
"follow_ups_modal_action_subject_placeholder": "Subiectul emailului",
"follow_ups_modal_action_to_description": "Adresă de email către care se trimite emailul",
"follow_ups_modal_action_to_label": "Către",
"follow_ups_modal_action_to_warning": "Nu s-a detectat niciun câmp de e-mail în sondaj",
"follow_ups_modal_action_to_warning": "Nu s-au găsit opțiuni valide pentru trimiterea e-mailurilor, vă rugăm să adăugați întrebări de tip text deschis / informații de contact sau câmpuri ascunse",
"follow_ups_modal_create_heading": "Creați o nouă urmărire",
"follow_ups_modal_created_successfull_toast": "Urmărirea a fost creată și va fi salvată odată ce salvați sondajul.",
"follow_ups_modal_edit_heading": "Editează acest follow-up",
"follow_ups_modal_edit_no_id": "Nu a fost furnizat un ID de urmărire al chestionarului, nu pot actualiza urmărirea chestionarului",
"follow_ups_modal_name_label": "Numele ",
@@ -1372,8 +1385,9 @@
"follow_ups_modal_trigger_label": "Declanșator",
"follow_ups_modal_trigger_type_ending": "Respondentul vede un sfârșit specific",
"follow_ups_modal_trigger_type_ending_select": "Selectează finalurile:",
"follow_ups_modal_trigger_type_ending_warning": "Nu s-au găsit finalizări în sondaj!",
"follow_ups_modal_trigger_type_ending_warning": "Vă rugăm să selectați cel puțin un sfârșit sau să schimbați tipul declanșatorului",
"follow_ups_modal_trigger_type_response": "Respondent finalizează sondajul",
"follow_ups_modal_updated_successfull_toast": "Urmărirea a fost actualizată și va fi salvată odată ce salvați sondajul.",
"follow_ups_new": "Follow-up nou",
"follow_ups_upgrade_button_text": "Actualizați pentru a activa urmărările",
"form_styling": "Stilizare formular",
@@ -1474,6 +1488,38 @@
"question_duplicated": "Întrebare duplicată.",
"question_id_updated": "ID întrebare actualizat",
"question_used_in_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.",
"question_used_in_quota": "Întrebarea aceasta este folosită în cota \"{quotaName}\"",
"quotas": {
"add_quota": "Adăugați cotă",
"change_quota_for_public_survey": "Schimbați cota pentru sondaj public?",
"confirm_quota_changes": "Confirmă modificările cotelor",
"confirm_quota_changes_body": "Aveți modificări nesalvate în quota dumneavoastră. Doriți să le salvați înainte de a pleca?",
"continue_survey_normally": "Continuă chestionarul în mod normal",
"count_partial_submissions": "Număr contestații parțiale",
"count_partial_submissions_description": "Includeți respondenții care îndeplinesc criteriile de cotă dar nu au completat sondajul",
"create_quota_for_public_survey": "Creați cotă pentru sondaj public?",
"create_quota_for_public_survey_description": "Doar răspunsurile viitoare vor fi încorporate în cotă",
"create_quota_for_public_survey_text": "Acest sondaj este deja public. Răspunsurile actuale nu vor fi luate în considerare pentru noua cotă.",
"delete_quota_confirmation_text": "Acest lucru va șterge definitiv cota {quotaName}.",
"duplicate_quota": "Duplicare cotă",
"edit_quota": "Editează cota",
"end_survey_for_matching_participants": "Încheiere sondaj pentru participanții eligibili",
"inclusion_criteria": "Criterii de includere",
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, one {Deja aveți {value} răspuns pentru această cotă, astfel încât limita trebuie să fie mai mare decât {value}.} other {Deja aveți {value} răspunsuri pentru această cotă, astfel încât limita trebuie să fie mai mare decât {value}.} }",
"limited_to_x_responses": "Limitat la {limit}",
"new_quota": "Contingent Nou",
"quota_created_successfull_toast": "\"Cota creată cu succes!\"",
"quota_deleted_successfull_toast": "\"Cota ștearsă cu succes!\"",
"quota_duplicated_successfull_toast": "\"Cota duplicată cu succes!\"",
"quota_name_placeholder": "de exemplu, Participanți cu vârsta 18-25 ani",
"quota_updated_successfull_toast": "\"Cota actualizată cu succes!\"",
"response_limit": "Limitări",
"save_changes_confirmation_body": "Orice modificări ale criteriilor de includere afectează doar răspunsurile viitoare. \nRecomandăm fie să duplicați un existent, fie să creați o nouă cotă.",
"save_changes_confirmation_text": "Răspunsurile existente rămân în cotă",
"select_ending_card": "Selectează cardul de finalizare",
"upgrade_prompt_title": "Folosește cote cu un plan superior",
"when_quota_has_been_reached": "Când cota a fost atinsă"
},
"randomize_all": "Randomizează tot",
"randomize_all_except_last": "Randomizează tot cu excepția ultimului",
"range": "Interval",
@@ -1567,6 +1613,7 @@
"url_not_supported": "URL nesuportat",
"use_with_caution": "Folosește cu precauție",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} este folosit în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
"variable_name_must_start_with_a_letter": "Numele variabilei trebuie să înceapă cu o literă.",
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
@@ -1601,11 +1648,14 @@
"address_line_2": "Adresă Linie 2",
"an_error_occurred_deleting_the_tag": "A apărut o eroare la ștergerea etichetei",
"browser": "Browser",
"bulk_delete_response_quotas": "Răspunsurile fac parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
"city": "Oraș",
"company": "Companie",
"completed": "Finalizat ✅",
"country": "Țară",
"decrement_quotas": "Decrementați toate limitele cotelor, inclusiv acest răspuns",
"delete_response_confirmation": "Aceasta va șterge răspunsul la sondaj, inclusiv toate răspunsurile, etichetele, documentele atașate și metadatele răspunsului.",
"delete_response_quotas": "Răspunsul face parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
"device": "Dispozitiv",
"device_info": "Informații despre dispozitiv",
"email": "Email",
@@ -1737,6 +1787,7 @@
"configure_alerts": "Configurează alertele",
"congrats": "Felicitări! Sondajul dumneavoastră este activ.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Conectează-ți site-ul sau aplicația cu Formbricks pentru a începe.",
"current_count": "Număr curent",
"custom_range": "Interval personalizat...",
"delete_all_existing_responses_and_displays": "Șterge toate răspunsurile și afișările existente",
"download_qr_code": "Descărcare cod QR",
@@ -1790,6 +1841,7 @@
"last_month": "Ultima lună",
"last_quarter": "Ultimul trimestru",
"last_year": "Anul trecut",
"limit": "Limită",
"no_responses_found": "Nu s-au găsit răspunsuri",
"other_values_found": "Alte valori găsite",
"overall": "General",
@@ -1798,6 +1850,8 @@
"qr_code_download_failed": "Descărcarea codului QR a eșuat",
"qr_code_download_with_start_soon": "Descărcarea codului QR va începe în curând",
"qr_code_generation_failed": "A apărut o problemă la încărcarea codului QR al chestionarului. Vă rugăm să încercați din nou.",
"quotas_completed": "Cote completate",
"quotas_completed_tooltip": "Numărul de cote completate de respondenți.",
"reset_survey": "Resetează chestionarul",
"reset_survey_warning": "Resetarea unui sondaj elimină toate răspunsurile și afișajele asociate cu acest sondaj. Aceasta nu poate fi anulată.",
"selected_responses_csv": "Răspunsuri selectate (CSV)",

View File

@@ -169,11 +169,14 @@
"connect_formbricks": "连接 Formbricks",
"connected": "已连接",
"contacts": "联系人",
"continue": "继续",
"copied": "已复制",
"copied_to_clipboard": "已 复制到 剪贴板",
"copy": "复制",
"copy_code": "复制 代码",
"copy_link": "复制 链接",
"count_contacts": "{value, plural, other {{value} 联系人} }",
"count_responses": "{value, plural, other {{value} 回复} }",
"create_new_organization": "创建 新的 组织",
"create_project": "创建 项目",
"create_segment": "创建 细分",
@@ -201,6 +204,7 @@
"e_commerce": "电子商务",
"edit": "编辑",
"email": "邮箱",
"ending_card": "结尾卡片",
"enterprise_license": "企业 许可证",
"environment_not_found": "环境 未找到",
"environment_notice": "你 目前 位于 {environment} 环境。",
@@ -269,6 +273,7 @@
"no_background_image_found": "未找到 背景 图片。",
"no_code": "无代码",
"no_files_uploaded": "没有 文件 被 上传",
"no_quotas_found": "未找到配额",
"no_result_found": "没有 结果",
"no_results": "没有 结果",
"no_surveys_found": "未找到 调查",
@@ -312,6 +317,7 @@
"product_manager": "产品经理",
"profile": "资料",
"profile_id": "资料 ID",
"progress": "进度",
"project_configuration": "项目 配置",
"project_creation_description": "将 调查 组织 在 项目 中 以 便于 更好 的 访问 控制。",
"project_id": "项目 ID",
@@ -323,6 +329,9 @@
"question": "问题",
"question_id": "问题 ID",
"questions": "问题",
"quota": "配额",
"quotas": "配额",
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
"read_docs": "阅读 文档",
"recipients": "收件人",
"remove": "移除",
@@ -370,6 +379,7 @@
"start_free_trial": "开始 免费试用",
"status": "状态",
"step_by_step_manual": "分步 手册",
"storage_not_configured": "文件存储 未设置,上传 可能 失败",
"styling": "样式",
"submit": "提交",
"summary": "概要",
@@ -579,6 +589,7 @@
"contacts_table_refresh": "刷新 联系人",
"contacts_table_refresh_success": "联系人 已成功刷新",
"delete_contact_confirmation": "这将删除与此联系人相关的所有调查问卷回复和联系人属性。基于此联系人数据的任何定位和个性化将会丢失。",
"delete_contact_confirmation_with_quotas": "{value, plural, one {这将删除与此联系人相关的所有调查回复和联系人属性。基于此联系人数据的任何定位和个性化将丢失。如果此联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。} other {这将删除与这些联系人相关的所有调查回复和联系人属性。基于这些联系人数据的任何定位和个性化将丢失。如果这些联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。}}",
"no_responses_found": "未找到 响应",
"not_provided": "未提供",
"search_contact": "搜索 联系人",
@@ -739,7 +750,6 @@
},
"project": {
"api_keys": {
"access_control": "访问控制",
"add_api_key": "添加 API 密钥",
"api_key": "API Key",
"api_key_copied_to_clipboard": "API 密钥 已复制到 剪贴板",
@@ -1280,7 +1290,7 @@
"columns": "列",
"company": "公司",
"company_logo": "公司 徽标",
"completed_responses": "部分 或 完成 的 反馈",
"completed_responses": "完成反馈",
"concat": "拼接 +",
"conditional_logic": "条件逻辑",
"confirm_default_language": "确认 默认 语言",
@@ -1320,6 +1330,7 @@
"end_screen_card": "结束 屏幕 卡片",
"ending_card": "结尾卡片",
"ending_card_used_in_logic": "\"这个 结束卡片 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
"ending_used_in_quota": "此 结尾 正在 被 \"{quotaName}\" 配额 使用",
"ends_with": "以...结束",
"equals": "等于",
"equals_one_of": "等于 其中 一个",
@@ -1330,6 +1341,7 @@
"fallback_for": "后备 用于",
"fallback_missing": "备用 缺失",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隐藏 字段 \"{fieldId}\" 正在 被 \"{quotaName}\" 配额 使用",
"field_name_eg_score_price": "字段 名称 例如 评分 ,价格",
"first_name": "名字",
"five_points_recommended": "5 点 (推荐)",
@@ -1361,8 +1373,9 @@
"follow_ups_modal_action_subject_placeholder": "电子邮件主题",
"follow_ups_modal_action_to_description": "发送邮件的电子邮箱地址",
"follow_ups_modal_action_to_label": "到",
"follow_ups_modal_action_to_warning": "调查中未 检测到 电子邮件 字段",
"follow_ups_modal_action_to_warning": "为 发送 邮件 找不到 有效 选项 ,请 增加 一些 开放文本 / 联系 信息 问题 或 隐藏 字段",
"follow_ups_modal_create_heading": "创建一个新的跟进",
"follow_ups_modal_created_successfull_toast": "后续 操作 已 创建, 并且 在 你 保存 调查 后 将 被 保存。",
"follow_ups_modal_edit_heading": "编辑此跟进",
"follow_ups_modal_edit_no_id": "未 提供 调查 跟进 id ,无法 更新 调查 跟进",
"follow_ups_modal_name_label": "跟进 名称",
@@ -1372,8 +1385,9 @@
"follow_ups_modal_trigger_label": "触发",
"follow_ups_modal_trigger_type_ending": "受访者 看到 一个 特定 的 结尾",
"follow_ups_modal_trigger_type_ending_select": "选择结尾:",
"follow_ups_modal_trigger_type_ending_warning": "在 调查 中 未找到 结尾 ",
"follow_ups_modal_trigger_type_ending_warning": "请选择至少 一个结束条件 或更改触发条件类型",
"follow_ups_modal_trigger_type_response": "受访者 完成 调查",
"follow_ups_modal_updated_successfull_toast": "后续 操作 已 更新, 并且 在 你 保存 调查 后 将 被 保存。",
"follow_ups_new": "新的跟进",
"follow_ups_upgrade_button_text": "升级 以启用 跟进",
"form_styling": "表单 样式",
@@ -1474,6 +1488,38 @@
"question_duplicated": "问题重复。",
"question_id_updated": "问题 ID 更新",
"question_used_in_logic": "\"这个 问题 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
"question_used_in_quota": "此 问题 正在 被 \"{quotaName}\" 配额 使用",
"quotas": {
"add_quota": "添加 配额",
"change_quota_for_public_survey": "更改 公共调查 的配额?",
"confirm_quota_changes": "确认配额变更",
"confirm_quota_changes_body": "您在配额中有未保存的更改。离开前是否要保存?",
"continue_survey_normally": "正常 继续 调查",
"count_partial_submissions": "统计 部分 提交",
"count_partial_submissions_description": "包含 符合 配额 标准 但 未 完成 调查 的 受访者",
"create_quota_for_public_survey": "为公共调查 创建 配额?",
"create_quota_for_public_survey_description": "只有未来的答案将纳入配额",
"create_quota_for_public_survey_text": "此 调查 已经 是 公开 的 。现有 的 回复 将 不 会 考虑 在 新 配额 中 。",
"delete_quota_confirmation_text": "这将永久删除配额 {quotaName}。",
"duplicate_quota": "复制 配额",
"edit_quota": "编辑 配额",
"end_survey_for_matching_participants": "为 符合 条件 的 参与者 结束 调查",
"inclusion_criteria": "纳入标准",
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {你已为此配额收到 {value} 个回复, 所以限额必须大于 {value}.} }",
"limited_to_x_responses": "限制 为 {limit}",
"new_quota": "新 配额",
"quota_created_successfull_toast": "配额 创建 成功",
"quota_deleted_successfull_toast": "配额 删除 成功",
"quota_duplicated_successfull_toast": "配额 复制 成功",
"quota_name_placeholder": "例如, 年龄 18-25 岁 参与者",
"quota_updated_successfull_toast": "配额 更新 成功",
"response_limit": "限额",
"save_changes_confirmation_body": "任何 对 包含 条件 的 更改 仅 影响 将来 的 响应。\n我们 建议 复制 一个 现有 的 或 创建 一个 新 的 配额。",
"save_changes_confirmation_text": "现有 的 响应 保留 在 配额 中",
"select_ending_card": "选择结尾卡片",
"upgrade_prompt_title": "在更高的计划中使用配额",
"when_quota_has_been_reached": "达到 配额 时"
},
"randomize_all": "随机排列",
"randomize_all_except_last": "随机排列,最后一个除外",
"range": "范围",
@@ -1567,6 +1613,7 @@
"url_not_supported": "URL 不支持",
"use_with_caution": "谨慎 使用",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{variable} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
"variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。",
"variable_name_must_start_with_a_letter": "变量名 必须 以字母开头。",
"verify_email_before_submission": "提交 之前 验证电子邮件",
@@ -1601,11 +1648,14 @@
"address_line_2": "地址 第2行",
"an_error_occurred_deleting_the_tag": "删除 标签 时发生错误",
"browser": "浏览器",
"bulk_delete_response_quotas": "这些 响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?",
"city": "城市",
"company": "公司",
"completed": "完成 ✅",
"country": "国家",
"decrement_quotas": "减少所有配额限制,包括此回应",
"delete_response_confirmation": "这 将 删除 调查 回应, 包括 所有 答案、 标签、 附件文档 和 回应元数据。",
"delete_response_quotas": "该响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?",
"device": "设备",
"device_info": "设备信息",
"email": "邮件",
@@ -1737,6 +1787,7 @@
"configure_alerts": "配置 警报",
"congrats": "恭喜!您的调查已上线。",
"connect_your_website_or_app_with_formbricks_to_get_started": "将您 的网站 或应用 与 Formbricks 连接 以开始 使用。",
"current_count": "当前数量",
"custom_range": "自定义 范围...",
"delete_all_existing_responses_and_displays": "删除 所有 现有 的 回复 和 显示",
"download_qr_code": "下载 二维码",
@@ -1790,6 +1841,7 @@
"last_month": "上个月",
"last_quarter": "上季度",
"last_year": "去年",
"limit": "限额",
"no_responses_found": "未找到响应",
"other_values_found": "找到其他值",
"overall": "整体",
@@ -1798,6 +1850,8 @@
"qr_code_download_failed": "二维码下载失败",
"qr_code_download_with_start_soon": "二维码下载将很快开始",
"qr_code_generation_failed": "加载 调查 QR 码 时出现问题。 请重试。",
"quotas_completed": "配额完成",
"quotas_completed_tooltip": "受访者完成的配额数量。",
"reset_survey": "重置 调查",
"reset_survey_warning": "重置 一个调查 会移除与 此调查 相关 的 所有响应 和 展示 。此操作 不能 撤销 。",
"selected_responses_csv": "选定 反馈 CSV",

View File

@@ -169,11 +169,14 @@
"connect_formbricks": "連線 Formbricks",
"connected": "已連線",
"contacts": "聯絡人",
"continue": "繼續",
"copied": "已 複製",
"copied_to_clipboard": "已複製到剪貼簿",
"copy": "複製",
"copy_code": "複製程式碼",
"copy_link": "複製連結",
"count_contacts": "{value, plural, other {{value} 聯絡人} }",
"count_responses": "{value, plural, other {{value} 回應} }",
"create_new_organization": "建立新組織",
"create_project": "建立專案",
"create_segment": "建立區隔",
@@ -201,6 +204,7 @@
"e_commerce": "電子商務",
"edit": "編輯",
"email": "電子郵件",
"ending_card": "結尾卡片",
"enterprise_license": "企業授權",
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
@@ -269,6 +273,7 @@
"no_background_image_found": "找不到背景圖片。",
"no_code": "無程式碼",
"no_files_uploaded": "沒有上傳任何檔案",
"no_quotas_found": "找不到 配額",
"no_result_found": "找不到結果",
"no_results": "沒有結果",
"no_surveys_found": "找不到問卷。",
@@ -312,6 +317,7 @@
"product_manager": "產品經理",
"profile": "個人資料",
"profile_id": "個人資料 ID",
"progress": "進度",
"project_configuration": "專案組態",
"project_creation_description": "組織調查 在 專案中以便更好地存取控制。",
"project_id": "專案 ID",
@@ -323,6 +329,9 @@
"question": "問題",
"question_id": "問題 ID",
"questions": "問題",
"quota": "配額",
"quotas": "額度",
"quotas_description": "限制 擁有 特定 條件 的 參與者 所 提供 的 回應 數量。",
"read_docs": "閱讀文件",
"recipients": "收件者",
"remove": "移除",
@@ -370,6 +379,7 @@
"start_free_trial": "開始免費試用",
"status": "狀態",
"step_by_step_manual": "逐步手冊",
"storage_not_configured": "檔案儲存未設定,上傳可能會失敗",
"styling": "樣式設定",
"submit": "提交",
"summary": "摘要",
@@ -579,6 +589,7 @@
"contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
"delete_contact_confirmation_with_quotas": "{value, plural, one {這將刪除與這個 contact 相關的所有調查響應和聯繫人屬性。基於這個 contact 數據的任何定向和個性化功能將會丟失。如果這個 contact 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。} other {這將刪除與這些 contacts 相關的所有調查響應和聯繫人屬性。基於這些 contacts 數據的任何定向和個性化功能將會丟失。如果這些 contacts 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。}}",
"no_responses_found": "找不到回應",
"not_provided": "未提供",
"search_contact": "搜尋聯絡人",
@@ -739,7 +750,6 @@
},
"project": {
"api_keys": {
"access_control": "存取控制",
"add_api_key": "新增 API 金鑰",
"api_key": "API 金鑰",
"api_key_copied_to_clipboard": "API 金鑰已複製到剪貼簿",
@@ -1280,7 +1290,7 @@
"columns": "欄位",
"company": "公司",
"company_logo": "公司標誌",
"completed_responses": "部分或完整答复。",
"completed_responses": "完成 回應",
"concat": "串連 +",
"conditional_logic": "條件邏輯",
"confirm_default_language": "確認預設語言",
@@ -1320,6 +1330,7 @@
"end_screen_card": "結束畫面卡片",
"ending_card": "結尾卡片",
"ending_card_used_in_logic": "此結尾卡片用於問題 '{'questionIndex'}' 的邏輯中。",
"ending_used_in_quota": "此 結尾 正被使用於 \"{quotaName}\" 配額中",
"ends_with": "結尾為",
"equals": "等於",
"equals_one_of": "等於其中之一",
@@ -1330,6 +1341,7 @@
"fallback_for": "備用 用於 ",
"fallback_missing": "遺失的回退",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隱藏欄位 \"{fieldId}\" 正被使用於 \"{quotaName}\" 配額中",
"field_name_eg_score_price": "欄位名稱,例如:分數、價格",
"first_name": "名字",
"five_points_recommended": "5 分(建議)",
@@ -1361,8 +1373,9 @@
"follow_ups_modal_action_subject_placeholder": "電子郵件主旨",
"follow_ups_modal_action_to_description": "傳送電子郵件的電子郵件地址",
"follow_ups_modal_action_to_label": "收件者",
"follow_ups_modal_action_to_warning": "問卷中未偵測到電子郵件欄位",
"follow_ups_modal_action_to_warning": "未找到 發送電子郵件 有效選項,請添加 一些 開放文本 / 聯絡資訊 問題或隱藏欄位",
"follow_ups_modal_create_heading": "建立新的後續追蹤",
"follow_ups_modal_created_successfull_toast": "後續 動作 已 建立 並 將 在 你 儲存 調查 後 儲存",
"follow_ups_modal_edit_heading": "編輯此後續追蹤",
"follow_ups_modal_edit_no_id": "未提供問卷後續追蹤 ID無法更新問卷後續追蹤",
"follow_ups_modal_name_label": "後續追蹤名稱",
@@ -1372,8 +1385,9 @@
"follow_ups_modal_trigger_label": "觸發器",
"follow_ups_modal_trigger_type_ending": "回應者看到特定結尾",
"follow_ups_modal_trigger_type_ending_select": "選取結尾:",
"follow_ups_modal_trigger_type_ending_warning": "問卷中找不到結尾!",
"follow_ups_modal_trigger_type_ending_warning": "請選擇至少一個結尾或更改觸發類型",
"follow_ups_modal_trigger_type_response": "回應者完成問卷",
"follow_ups_modal_updated_successfull_toast": "後續 動作 已 更新 並 將 在 你 儲存 調查 後 儲存",
"follow_ups_new": "新增後續追蹤",
"follow_ups_upgrade_button_text": "升級以啟用後續追蹤",
"form_styling": "表單樣式設定",
@@ -1474,6 +1488,38 @@
"question_duplicated": "問題已複製。",
"question_id_updated": "問題 ID 已更新",
"question_used_in_logic": "此問題用於問題 '{'questionIndex'}' 的邏輯中。",
"question_used_in_quota": "此問題 正被使用於 \"{quotaName}\" 配額中",
"quotas": {
"add_quota": "新增額度",
"change_quota_for_public_survey": "更改 公開 問卷 的 額度?",
"confirm_quota_changes": "確認配額變更",
"confirm_quota_changes_body": "您的 配額 中有 未儲存 的 變更。您 要 先 儲存 它們 再 離開 嗎?",
"continue_survey_normally": "正常 繼續 問卷",
"count_partial_submissions": "計算 部分提交",
"count_partial_submissions_description": "包括符合配額標準但未完成問卷的受訪者",
"create_quota_for_public_survey": "為 公開 問卷 建立 額度?",
"create_quota_for_public_survey_description": "只有 未來 的 答案 會 被 篩選 進 配額",
"create_quota_for_public_survey_text": "這個 調查 已經 是 公開 的。 現有 的 回應 將 不會 被 納入 新 額度 的 考量。",
"delete_quota_confirmation_text": "這將永久刪除配額 {quotaName}。",
"duplicate_quota": "複製 配額",
"edit_quota": "編輯 配額",
"end_survey_for_matching_participants": "結束問卷調查 對於 符合條件的參加者",
"inclusion_criteria": "納入 條件",
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {您已經有 {value} 個 回應 對於 此 配額,因此 限制 必須大於 {value}。} }",
"limited_to_x_responses": "限制為 {limit}",
"new_quota": "新 配額",
"quota_created_successfull_toast": "配額已成功建立。",
"quota_deleted_successfull_toast": "配額已成功刪除。",
"quota_duplicated_successfull_toast": "配額已成功複製。",
"quota_name_placeholder": "例如, 年齡 18-25 參與者",
"quota_updated_successfull_toast": "配額已成功更新",
"response_limit": "限制",
"save_changes_confirmation_body": "任何 變更 包括 條件 只 影響 未來 的 回覆。\n 我們 推薦 複製 現有 的 配額 或 創建 新 的 配額。",
"save_changes_confirmation_text": "現有 回應 留在 配額 內",
"select_ending_card": "選取結尾卡片",
"upgrade_prompt_title": "使用 額度 與 更高 的 計劃",
"when_quota_has_been_reached": "當 配額 已達"
},
"randomize_all": "全部隨機排序",
"randomize_all_except_last": "全部隨機排序(最後一項除外)",
"range": "範圍",
@@ -1567,6 +1613,7 @@
"url_not_supported": "不支援網址",
"use_with_caution": "謹慎使用",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'variable'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
"variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。",
"variable_name_must_start_with_a_letter": "變數名稱必須以字母開頭。",
"verify_email_before_submission": "提交前驗證電子郵件",
@@ -1601,11 +1648,14 @@
"address_line_2": "地址 2",
"an_error_occurred_deleting_the_tag": "刪除標籤時發生錯誤",
"browser": "瀏覽器",
"bulk_delete_response_quotas": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?",
"city": "城市",
"company": "公司",
"completed": "已完成 ✅",
"country": "國家/地區",
"decrement_quotas": "減少所有配額限制,包括此回應",
"delete_response_confirmation": "這將刪除調查響應,包括所有回答、標籤、附件文件以及響應元數據。",
"delete_response_quotas": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?",
"device": "裝置",
"device_info": "裝置資訊",
"email": "電子郵件",
@@ -1737,6 +1787,7 @@
"configure_alerts": "設定警示",
"congrats": "恭喜!您的問卷已上線。",
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
"current_count": "目前計數",
"custom_range": "自訂範圍...",
"delete_all_existing_responses_and_displays": "刪除 所有 現有 回應 和 顯示",
"download_qr_code": "下載 QR code",
@@ -1790,6 +1841,7 @@
"last_month": "上個月",
"last_quarter": "上一季",
"last_year": "去年",
"limit": "限制",
"no_responses_found": "找不到回應",
"other_values_found": "找到其他值",
"overall": "整體",
@@ -1798,6 +1850,8 @@
"qr_code_download_failed": "QR code 下載失敗",
"qr_code_download_with_start_soon": "QR code 下載即將開始",
"qr_code_generation_failed": "載入調查 QR Code 時發生問題。請再試一次。",
"quotas_completed": "配額 已完成",
"quotas_completed_tooltip": "受訪者完成的 配額 數量。",
"reset_survey": "重設問卷",
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
"selected_responses_csv": "選擇的回應 (CSV)",

View File

@@ -13,6 +13,7 @@ import { RatingResponse } from "@/modules/ui/components/rating-response";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react";
import React from "react";
import { TResponseDataValue } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyMatrixQuestion,
@@ -23,7 +24,7 @@ import {
} from "@formbricks/types/surveys/types";
interface RenderResponseProps {
responseData: string | number | string[] | Record<string, string>;
responseData: TResponseDataValue;
question: TSurveyQuestion;
survey: TSurvey;
language: string | null;

View File

@@ -1,4 +1,6 @@
export const isValidValue = (value: string | number | Record<string, string> | string[]) => {
import { TResponseDataValue } from "@formbricks/types/responses";
export const isValidValue = (value: TResponseDataValue) => {
return (
(typeof value === "string" && value.trim() !== "") ||
(Array.isArray(value) && value.length > 0) ||

View File

@@ -0,0 +1,101 @@
import { getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { type OverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { type ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
/**
* Check if the main database is reachable and responding
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the database health check
*/
export const checkDatabaseHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
// Simple query to check if database is reachable
await prisma.$queryRaw`SELECT 1`;
return ok(true);
} catch (error) {
logger
.withContext({
component: "health_check",
check_type: "main_database",
error,
})
.error("Database health check failed");
return err({
type: "internal_server_error",
details: [{ field: "main_database", issue: "Database health check failed" }],
});
}
};
/**
* Check if the Redis cache is reachable and responding
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the cache health check
*/
export const checkCacheHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
const cacheServiceResult = await getCacheService();
if (!cacheServiceResult.ok) {
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Cache service not available" }],
});
}
const isAvailable = await cacheServiceResult.data.isRedisAvailable();
if (isAvailable) {
return ok(true);
}
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Redis not available" }],
});
} catch (error) {
logger
.withContext({
component: "health_check",
check_type: "cache_database",
error,
})
.error("Redis health check failed");
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Redis health check failed" }],
});
}
};
/**
* Perform all health checks and return the overall status
* Always returns ok() with health status unless the health check endpoint itself fails
* @returns Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> - Overall health status of all dependencies
*/
export const performHealthChecks = async (): Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> => {
try {
const [databaseResult, cacheResult] = await Promise.all([checkDatabaseHealth(), checkCacheHealth()]);
const healthStatus: OverallHealthStatus = {
main_database: databaseResult.ok ? databaseResult.data : false,
cache_database: cacheResult.ok ? cacheResult.data : false,
};
// Always return ok() with the health status - individual dependency failures
// are reflected in the boolean values
return ok(healthStatus);
} catch (error) {
// Only return err() if the health check endpoint itself fails
logger
.withContext({
component: "health_check",
error,
})
.error("Health check endpoint failed");
return err({
type: "internal_server_error",
details: [{ field: "health", issue: "Failed to perform health checks" }],
});
}
};

View File

@@ -0,0 +1,29 @@
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZodOpenApiOperationObject } from "zod-openapi";
export const healthCheckEndpoint: ZodOpenApiOperationObject = {
tags: ["Health"],
summary: "Health Check",
description: "Check the health status of critical application dependencies including database and cache.",
requestParams: {},
operationId: "healthCheck",
security: [],
responses: {
"200": {
description:
"Health check completed successfully. Check individual dependency status in response data.",
content: {
"application/json": {
schema: makePartialSchema(ZOverallHealthStatus),
},
},
},
},
};
export const healthPaths = {
"/health": {
get: healthCheckEndpoint,
},
};

View File

@@ -0,0 +1,288 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { err, ok } from "@formbricks/types/error-handlers";
import { checkCacheHealth, checkDatabaseHealth, performHealthChecks } from "../health-checks";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
$queryRaw: vi.fn(),
},
}));
vi.mock("@formbricks/cache", () => ({
getCacheService: vi.fn(),
ErrorCode: {
RedisConnectionError: "redis_connection_error",
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
withContext: vi.fn(() => ({
error: vi.fn(),
info: vi.fn(),
})),
},
}));
describe("Health Checks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Helper function to create a mock CacheService
const createMockCacheService = (isRedisAvailable: boolean = true) => ({
getRedisClient: vi.fn(),
withTimeout: vi.fn(),
get: vi.fn(),
exists: vi.fn(),
set: vi.fn(),
del: vi.fn(),
keys: vi.fn(),
withCache: vi.fn(),
flush: vi.fn(),
tryGetCachedValue: vi.fn(),
trySetCache: vi.fn(),
isRedisAvailable: vi.fn().mockResolvedValue(isRedisAvailable),
});
describe("checkDatabaseHealth", () => {
test("should return healthy when database query succeeds", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
const result = await checkDatabaseHealth();
expect(result).toEqual({ ok: true, data: true });
expect(prisma.$queryRaw).toHaveBeenCalledWith(["SELECT 1"]);
});
test("should return unhealthy when database query fails", async () => {
const dbError = new Error("Database connection failed");
vi.mocked(prisma.$queryRaw).mockRejectedValue(dbError);
const result = await checkDatabaseHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "main_database", issue: "Database health check failed" },
]);
}
});
test("should handle different types of database errors", async () => {
const networkError = new Error("ECONNREFUSED");
vi.mocked(prisma.$queryRaw).mockRejectedValue(networkError);
const result = await checkDatabaseHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "main_database", issue: "Database health check failed" },
]);
}
});
});
describe("checkCacheHealth", () => {
test("should return healthy when Redis is available", async () => {
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result).toEqual({ ok: true, data: true });
expect(getCacheService).toHaveBeenCalled();
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
});
test("should return unhealthy when cache service fails to initialize", async () => {
const cacheError = { code: ErrorCode.RedisConnectionError };
vi.mocked(getCacheService).mockResolvedValue(err(cacheError));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Cache service not available" },
]);
}
});
test("should return unhealthy when Redis is not available", async () => {
const mockCacheService = createMockCacheService(false);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "cache_database", issue: "Redis not available" }]);
}
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
});
test("should handle Redis availability check exceptions", async () => {
const mockCacheService = createMockCacheService(true);
mockCacheService.isRedisAvailable.mockRejectedValue(new Error("Redis ping failed"));
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Redis health check failed" },
]);
}
});
test("should handle cache service initialization exceptions", async () => {
const serviceException = new Error("Cache service unavailable");
vi.mocked(getCacheService).mockRejectedValue(serviceException);
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Redis health check failed" },
]);
}
});
test("should verify isRedisAvailable is called asynchronously", async () => {
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
await checkCacheHealth();
// Verify the async method was called
expect(mockCacheService.isRedisAvailable).toHaveBeenCalledTimes(1);
expect(mockCacheService.isRedisAvailable).toReturnWith(Promise.resolve(true));
});
});
describe("performHealthChecks", () => {
test("should return all healthy when both checks pass", async () => {
// Mock successful database check
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
// Mock successful cache check
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: true,
cache_database: true,
},
});
});
test("should return mixed results when only database is healthy", async () => {
// Mock successful database check
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
// Mock failed cache check
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: true,
cache_database: false,
},
});
});
test("should return mixed results when only cache is healthy", async () => {
// Mock failed database check
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
// Mock successful cache check
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: false,
cache_database: true,
},
});
});
test("should return all unhealthy when both checks fail", async () => {
// Mock failed database check
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
// Mock failed cache check
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: false,
cache_database: false,
},
});
});
test("should run both checks in parallel", async () => {
const dbPromise = new Promise((resolve) => setTimeout(() => resolve([{ "?column?": 1 }]), 100));
const redisPromise = new Promise((resolve) => setTimeout(() => resolve(true), 100));
vi.mocked(prisma.$queryRaw).mockReturnValue(dbPromise as any);
const mockCacheService = createMockCacheService(true);
mockCacheService.isRedisAvailable.mockReturnValue(redisPromise as any);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const startTime = Date.now();
await performHealthChecks();
const endTime = Date.now();
// Should complete in roughly 100ms (parallel) rather than 200ms (sequential)
expect(endTime - startTime).toBeLessThan(150);
});
test("should return error only on catastrophic failure (endpoint itself fails)", async () => {
// Mock a catastrophic failure in Promise.all itself
const originalPromiseAll = Promise.all;
vi.spyOn(Promise, "all").mockRejectedValue(new Error("Catastrophic system failure"));
const result = await performHealthChecks();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "health", issue: "Failed to perform health checks" }]);
}
// Restore original Promise.all
Promise.all = originalPromiseAll;
});
});
});

View File

@@ -0,0 +1,15 @@
import { responses } from "@/modules/api/v2/lib/response";
import { performHealthChecks } from "./lib/health-checks";
export const GET = async () => {
const healthStatusResult = await performHealthChecks();
if (!healthStatusResult.ok) {
return responses.serviceUnavailableResponse({
details: healthStatusResult.error.details,
});
}
return responses.successResponse({
data: healthStatusResult.data,
});
};

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOverallHealthStatus = z
.object({
main_database: z.boolean().openapi({
description: "Main database connection status - true if database is reachable and running",
example: true,
}),
cache_database: z.boolean().openapi({
description: "Cache database connection status - true if cache database is reachable and running",
example: true,
}),
})
.openapi({
title: "Health Check Response",
description: "Health check status for critical application dependencies",
});
export type OverallHealthStatus = z.infer<typeof ZOverallHealthStatus>;

View File

@@ -232,6 +232,35 @@ const internalServerErrorResponse = ({
);
};
const serviceUnavailableResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 503,
message: "Service Unavailable",
details,
},
},
{
status: 503,
headers,
}
);
};
const successResponse = ({
data,
meta,
@@ -325,6 +354,7 @@ export const responses = {
unprocessableEntityResponse,
tooManyRequestsResponse,
internalServerErrorResponse,
serviceUnavailableResponse,
successResponse,
createdResponse,
multiStatusResponse,

View File

@@ -1,3 +1,5 @@
import { healthPaths } from "@/modules/api/v2/health/lib/openapi";
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
import { surveyContactLinksBySegmentPaths } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/lib/openapi";
@@ -35,6 +37,7 @@ const document = createDocument({
version: "2.0.0",
},
paths: {
...healthPaths,
...rolePaths,
...mePaths,
...responsePaths,
@@ -55,6 +58,10 @@ const document = createDocument({
},
],
tags: [
{
name: "Health",
description: "Operations for checking critical application dependencies health status.",
},
{
name: "Roles",
description: "Operations for managing roles.",
@@ -114,6 +121,7 @@ const document = createDocument({
},
},
schemas: {
health: ZOverallHealthStatus,
role: ZRoles,
me: ZApiKeyData,
response: ZResponse,

View File

@@ -2,6 +2,7 @@
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { CopyIcon, Trash2Icon } from "lucide-react";
import { TSurveyQuota } from "@formbricks/types/quota";
@@ -37,26 +38,30 @@ export const QuotaList = ({ quotas, onEdit, deleteQuota, duplicateQuota }: Quota
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<Trash2Icon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
duplicateQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<CopyIcon className="h-4 w-4" />
</Button>
<TooltipRenderer tooltipContent={t("common.delete")}>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<Trash2Icon className="h-4 w-4" />
</Button>
</TooltipRenderer>
<TooltipRenderer tooltipContent={t("common.duplicate")}>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
duplicateQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<CopyIcon className="h-4 w-4" />
</Button>
</TooltipRenderer>
</div>
</div>
))}

View File

@@ -1,6 +1,5 @@
"use client";
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
@@ -24,7 +23,7 @@ import { Switch } from "@/modules/ui/components/switch";
import { ApiKeyPermission } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, Trash2Icon } from "lucide-react";
import { Fragment, useState } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TOrganizationAccess } from "@formbricks/types/api-key";
@@ -220,10 +219,10 @@ export const AddApiKeyModal = ({
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.project.api_keys.add_api_key")}</DialogTitle>
<DialogTitle className="px-1">{t("environments.project.api_keys.add_api_key")}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submitAPIKey)} className="contents">
<DialogBody className="space-y-4 overflow-y-auto py-4">
<DialogBody className="space-y-4 overflow-y-auto px-1 py-4">
<div className="space-y-2">
<Label>{t("environments.project.api_keys.api_key_label")}</Label>
<Input
@@ -348,43 +347,31 @@ export const AddApiKeyModal = ({
</div>
<div className="space-y-4">
<div>
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<p className="text-sm text-slate-500">
{t("environments.project.api_keys.organization_access_description")}
</p>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(selectedOrganizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-read`}
checked={selectedOrganizationAccess[key].read}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "read", newVal)
}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-write`}
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "write", newVal)
}
/>
</div>
</Fragment>
))}
<Label>{t("environments.project.api_keys.organization_access")}</Label>
{Object.keys(selectedOrganizationAccess).map((key) => (
<div key={key} className="mt-2 flex items-center gap-6">
<div className="flex items-center gap-2">
<Label>Read</Label>
<Switch
data-testid={`organization-access-${key}-read`}
checked={selectedOrganizationAccess[key].read || selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) => setSelectedOrganizationAccessValue(key, "read", newVal)}
disabled={selectedOrganizationAccess[key].write}
/>
</div>
<div className="flex items-center gap-2">
<Label>Write</Label>
<Switch
data-testid={`organization-access-${key}-write`}
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) => setSelectedOrganizationAccessValue(key, "write", newVal)}
/>
</div>
</div>
</div>
))}
<p className="text-sm text-slate-500">
{t("environments.project.api_keys.organization_access_description")}
</p>
</div>
<Alert variant="warning">
<AlertTitle>{t("environments.project.api_keys.api_key_security_warning")}</AlertTitle>

View File

@@ -1,7 +1,6 @@
import { ApiKeyPermission } from "@prisma/client";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TApiKeyWithEnvironmentPermission } from "../types/api-keys";
@@ -104,6 +103,8 @@ describe("ViewPermissionModal", () => {
setOpen: vi.fn(),
projects: mockProjects,
apiKey: mockApiKey,
onSubmit: vi.fn(),
isUpdating: false,
};
test("renders the modal with correct title", () => {
@@ -154,7 +155,7 @@ describe("ViewPermissionModal", () => {
expect(screen.getByTestId("organization-access-accessControl-read")).toBeDisabled();
expect(screen.getByTestId("organization-access-accessControl-write")).not.toBeChecked();
expect(screen.getByTestId("organization-access-accessControl-write")).toBeDisabled();
expect(screen.getByTestId("organization-access-otherAccess-read")).not.toBeChecked();
expect(screen.getByTestId("organization-access-otherAccess-read")).toBeChecked();
expect(screen.getByTestId("organization-access-otherAccess-write")).toBeChecked();
});
});

View File

@@ -1,6 +1,5 @@
"use client";
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
import {
TApiKeyUpdateInput,
TApiKeyWithEnvironmentPermission,
@@ -22,7 +21,7 @@ import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { Fragment, useEffect } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { TOrganizationAccess } from "@formbricks/types/api-key";
@@ -168,36 +167,28 @@ export const ViewPermissionModal = ({
})}
</div>
</div>
<div className="space-y-2">
<div className="space-y-4">
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(organizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-read`}
checked={organizationAccess[key].read}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-write`}
checked={organizationAccess[key].write}
/>
</div>
</Fragment>
))}
{Object.keys(organizationAccess).map((key) => (
<div key={key} className="mb-2 flex items-center gap-6">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">Read</Label>
<Switch
disabled={true}
data-testid={`organization-access-${key}-read`}
checked={organizationAccess[key].read || organizationAccess[key].write}
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">Write</Label>
<Switch
disabled={true}
data-testid={`organization-access-${key}-write`}
checked={organizationAccess[key].write}
/>
</div>
</div>
</div>
))}
</div>
</div>
</form>

View File

@@ -1,6 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { getOrganizationAccessKeyDisplayName, hasPermission } from "./utils";
import { hasPermission } from "./utils";
describe("hasPermission", () => {
const envId = "env1";
@@ -83,17 +83,3 @@ describe("hasPermission", () => {
expect(hasPermission(permissions, "other", "GET")).toBe(false);
});
});
describe("getOrganizationAccessKeyDisplayName", () => {
test("returns tolgee string for accessControl", () => {
const t = vi.fn((k) => k);
expect(getOrganizationAccessKeyDisplayName("accessControl", t)).toBe(
"environments.project.api_keys.access_control"
);
expect(t).toHaveBeenCalledWith("environments.project.api_keys.access_control");
});
test("returns tolgee string for other keys", () => {
const t = vi.fn((k) => k);
expect(getOrganizationAccessKeyDisplayName("otherKey", t)).toBe("otherKey");
});
});

View File

@@ -1,4 +1,3 @@
import { TFnType } from "@tolgee/react";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import { TAPIKeyEnvironmentPermission, TAuthenticationApiKey } from "@formbricks/types/auth";
@@ -43,15 +42,6 @@ export const hasPermission = (
}
};
export const getOrganizationAccessKeyDisplayName = (key: string, t: TFnType) => {
switch (key) {
case "accessControl":
return t("environments.project.api_keys.access_control");
default:
return key;
}
};
export const hasOrganizationAccess = (
authentication: TAuthenticationApiKey,
accessType: OrganizationAccessType

View File

@@ -32,13 +32,7 @@ describe("TemplateFilters", () => {
test("renders all filter categories and options", () => {
const setSelectedFilter = vi.fn();
render(
<TemplateFilters
selectedFilter={[null, null, null]}
setSelectedFilter={setSelectedFilter}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateFilters selectedFilter={[null, null, null]} setSelectedFilter={setSelectedFilter} />);
expect(screen.getByText("environments.surveys.templates.all_channels")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.templates.all_industries")).toBeInTheDocument();
@@ -54,13 +48,7 @@ describe("TemplateFilters", () => {
const setSelectedFilter = vi.fn();
const user = userEvent.setup();
render(
<TemplateFilters
selectedFilter={[null, null, null]}
setSelectedFilter={setSelectedFilter}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateFilters selectedFilter={[null, null, null]} setSelectedFilter={setSelectedFilter} />);
await user.click(screen.getByText("environments.surveys.templates.channel1"));
expect(setSelectedFilter).toHaveBeenCalledWith(["channel1", null, null]);
@@ -74,11 +62,7 @@ describe("TemplateFilters", () => {
const user = userEvent.setup();
render(
<TemplateFilters
selectedFilter={["link", "app", "website"]}
setSelectedFilter={setSelectedFilter}
prefilledFilters={[null, null, null]}
/>
<TemplateFilters selectedFilter={["link", "app", "website"]} setSelectedFilter={setSelectedFilter} />
);
await user.click(screen.getByText("environments.surveys.templates.all_channels"));
@@ -93,7 +77,6 @@ describe("TemplateFilters", () => {
selectedFilter={[null, null, null]}
setSelectedFilter={setSelectedFilter}
templateSearch="search term"
prefilledFilters={[null, null, null]}
/>
);
@@ -102,20 +85,4 @@ describe("TemplateFilters", () => {
expect(button).toBeDisabled();
});
});
test("does not render filter categories that are prefilled", () => {
const setSelectedFilter = vi.fn();
render(
<TemplateFilters
selectedFilter={["link", null, null]}
setSelectedFilter={setSelectedFilter}
prefilledFilters={["link", null, null]}
/>
);
expect(screen.queryByText("environments.surveys.templates.all_channels")).not.toBeInTheDocument();
expect(screen.getByText("environments.surveys.templates.all_industries")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.templates.all_roles")).toBeInTheDocument();
});
});

View File

@@ -9,14 +9,12 @@ interface TemplateFiltersProps {
selectedFilter: TTemplateFilter[];
setSelectedFilter: (filter: TTemplateFilter[]) => void;
templateSearch?: string;
prefilledFilters: TTemplateFilter[];
}
export const TemplateFilters = ({
selectedFilter,
setSelectedFilter,
templateSearch,
prefilledFilters,
}: TemplateFiltersProps) => {
const { t } = useTranslate();
const handleFilterSelect = (filterValue: TTemplateFilter, index: number) => {
@@ -31,7 +29,6 @@ export const TemplateFilters = ({
return (
<div className="mb-6 gap-3">
{allFilters.map((filters, index) => {
if (prefilledFilters[index] !== null) return;
return (
<div key={filters[0]?.value || index} className="mt-2 flex flex-wrap gap-1 last:border-r-0">
<button

View File

@@ -102,41 +102,20 @@ describe("TemplateList", () => {
});
test("renders correctly with default props", () => {
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
expect(screen.getByText("Start from scratch")).toBeInTheDocument();
});
test("renders filters when showFilters is true", () => {
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
showFilters={true}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} showFilters={true} />);
expect(screen.queryByTestId("template-filters")).toBeInTheDocument();
});
test("doesn't render filters when showFilters is false", () => {
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
showFilters={false}
/>
<TemplateList userId="user-id" environmentId="env-id" project={mockProject} showFilters={false} />
);
expect(screen.queryByTestId("template-filters")).not.toBeInTheDocument();
@@ -150,7 +129,6 @@ describe("TemplateList", () => {
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
templateSearch="Template 1"
/>
);
@@ -167,7 +145,6 @@ describe("TemplateList", () => {
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
onTemplateClick={onTemplateClickMock}
noPreview={true}
/>
@@ -186,14 +163,7 @@ describe("TemplateList", () => {
const user = userEvent.setup();
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
// First select the template
const selectButton = screen.getAllByText("Select")[0];
@@ -220,14 +190,7 @@ describe("TemplateList", () => {
const user = userEvent.setup();
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
// First select the template
const selectButton = screen.getAllByText("Select")[0];
@@ -250,12 +213,7 @@ describe("TemplateList", () => {
};
const { rerender } = render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mobileProject as Project}
prefilledFilters={[null, null, null]}
/>
<TemplateList userId="user-id" environmentId="env-id" project={mobileProject as Project} />
);
// Test with no channel config
@@ -264,14 +222,7 @@ describe("TemplateList", () => {
config: {},
};
rerender(
<TemplateList
userId="user-id"
environmentId="env-id"
project={noChannelProject as Project}
prefilledFilters={[null, null, null]}
/>
);
rerender(<TemplateList userId="user-id" environmentId="env-id" project={noChannelProject as Project} />);
expect(screen.getByText("Template 1")).toBeInTheDocument();
});
@@ -279,14 +230,7 @@ describe("TemplateList", () => {
test("development mode shows templates correctly", () => {
vi.stubEnv("NODE_ENV", "development");
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
expect(screen.getByText("Template 1")).toBeInTheDocument();
expect(screen.getByText("Template 2")).toBeInTheDocument();

View File

@@ -21,7 +21,6 @@ interface TemplateListProps {
project: Project;
templateSearch?: string;
showFilters?: boolean;
prefilledFilters: TTemplateFilter[];
onTemplateClick?: (template: TTemplate) => void;
noPreview?: boolean; // single click to create survey
}
@@ -32,7 +31,6 @@ export const TemplateList = ({
environmentId,
showFilters = true,
templateSearch,
prefilledFilters,
onTemplateClick = () => {},
noPreview,
}: TemplateListProps) => {
@@ -40,7 +38,7 @@ export const TemplateList = ({
const router = useRouter();
const [activeTemplate, setActiveTemplate] = useState<TTemplate | null>(null);
const [loading, setLoading] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<TTemplateFilter[]>(prefilledFilters);
const [selectedFilter, setSelectedFilter] = useState<TTemplateFilter[]>([null, null, null]);
const surveyType: TSurveyType = useMemo(() => {
if (project.config.channel) {
if (project.config.channel === "website") {
@@ -111,7 +109,6 @@ export const TemplateList = ({
selectedFilter={selectedFilter}
setSelectedFilter={setSelectedFilter}
templateSearch={templateSearch}
prefilledFilters={prefilledFilters}
/>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">

View File

@@ -241,4 +241,40 @@ describe("ConditionalLogic", () => {
expect(screen.getAllByTestId("logic-editor").length).toBe(2);
});
test("should clear logicFallback when logic array is empty and logicFallback exists (useEffect)", () => {
const mockUpdateQuestion = vi.fn();
const mockQuestion: TSurveyQuestion = {
id: "testQuestionId",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Test Question" },
required: false,
inputType: "text",
charLimit: { enabled: false },
logic: [], // Empty logic array
logicFallback: "someTarget", // Has logicFallback but no logic
};
const mockSurvey = {
id: "testSurveyId",
name: "Test Survey",
type: "link",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "testEnvId",
status: "inProgress",
questions: [mockQuestion],
endings: [],
} as unknown as TSurvey;
render(
<ConditionalLogic
localSurvey={mockSurvey}
question={mockQuestion}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
/>
);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { logicFallback: undefined });
});
});

View File

@@ -1,5 +1,19 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import {
ArrowDownIcon,
ArrowUpIcon,
CopyIcon,
EllipsisVerticalIcon,
PlusIcon,
SplitIcon,
TrashIcon,
} from "lucide-react";
import { useEffect, useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { duplicateLogicItem } from "@/lib/surveyLogic/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { LogicEditor } from "@/modules/survey/editor/components/logic-editor";
@@ -15,20 +29,6 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Label } from "@/modules/ui/components/label";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import {
ArrowDownIcon,
ArrowUpIcon,
CopyIcon,
EllipsisVerticalIcon,
PlusIcon,
SplitIcon,
TrashIcon,
} from "lucide-react";
import { useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
interface ConditionalLogicProps {
localSurvey: TSurvey;
@@ -117,6 +117,12 @@ export function ConditionalLogic({
};
const [parent] = useAutoAnimate();
useEffect(() => {
if (question.logic?.length === 0 && question.logicFallback) {
updateQuestion(questionIdx, { logicFallback: undefined });
}
}, [question.logic, questionIdx, question.logicFallback, updateQuestion]);
return (
<div className="mt-4" ref={parent}>
<Label className="flex gap-2">

View File

@@ -1,10 +1,10 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { TConditionGroup, TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { createSharedConditionsFactory } from "@/modules/survey/editor/lib/shared-conditions-factory";
import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils";
import { ConditionsEditor } from "@/modules/ui/components/conditions-editor";
import { useTranslate } from "@tolgee/react";
import { TConditionGroup, TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
interface LogicEditorConditionsProps {
conditions: TConditionGroup;

View File

@@ -1,5 +1,3 @@
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
@@ -8,6 +6,8 @@ import {
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
import { LogicEditor } from "./logic-editor";
// Mock the subcomponents to isolate the LogicEditor component

View File

@@ -1,5 +1,9 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { ArrowRightIcon } from "lucide-react";
import { ReactElement, useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
@@ -11,10 +15,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react";
import { ArrowRightIcon } from "lucide-react";
import { ReactElement, useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
interface LogicEditorProps {
localSurvey: TSurvey;

View File

@@ -8,7 +8,7 @@ import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import { CopyPlusIcon, TrashIcon } from "lucide-react";
import { CopyIcon, Trash2Icon } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
@@ -154,7 +154,7 @@ export const FollowUpItem = ({
setDeleteFollowUpModalOpen(true);
}}
aria-label={t("common.delete")}>
<TrashIcon className="h-4 w-4 text-slate-500" />
<Trash2Icon className="h-4 w-4 text-slate-500" />
</Button>
</TooltipRenderer>
@@ -167,7 +167,7 @@ export const FollowUpItem = ({
duplicateFollowUp();
}}
aria-label={t("common.duplicate")}>
<CopyPlusIcon className="h-4 w-4 text-slate-500" />
<CopyIcon className="h-4 w-4 text-slate-500" />
</Button>
</TooltipRenderer>
</div>

View File

@@ -1,5 +1,24 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import DOMpurify from "isomorphic-dompurify";
import {
ArrowDownIcon,
EyeOffIcon,
HandshakeIcon,
MailIcon,
TriangleAlertIcon,
UserIcon,
ZapIcon,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/database/types/survey-follow-up";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { recallToHeadline } from "@/lib/utils/recall";
import { getSurveyFollowUpActionDefaultBody } from "@/modules/survey/editor/lib/utils";
@@ -41,25 +60,6 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { cn } from "@/modules/ui/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import DOMpurify from "isomorphic-dompurify";
import {
ArrowDownIcon,
EyeOffIcon,
HandshakeIcon,
MailIcon,
TriangleAlertIcon,
UserIcon,
ZapIcon,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/database/types/survey-follow-up";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
interface AddFollowUpModalProps {
localSurvey: TSurvey;
@@ -186,14 +186,12 @@ export const FollowUpModal = ({
const handleSubmit = (data: TCreateSurveyFollowUpForm) => {
if (data.triggerType === "endings" && data.endingIds?.length === 0) {
toast.error("Please select at least one ending or change the trigger type");
toast.error(t("environments.surveys.edit.follow_ups_modal_trigger_type_ending_warning"));
return;
}
if (!emailSendToOptions.length) {
toast.error(
"No valid options found for sending emails, please add some open-text / contact-info questions or hidden fields"
);
toast.error(t("environments.surveys.edit.follow_ups_modal_action_to_warning"));
return;
}
@@ -264,7 +262,7 @@ export const FollowUpModal = ({
},
};
toast.success("Survey follow up updated successfully");
toast.success(t("environments.surveys.edit.follow_ups_modal_updated_successfull_toast"));
setOpen(false);
setLocalSurvey((prev) => {
return {
@@ -311,7 +309,7 @@ export const FollowUpModal = ({
},
};
toast.success("Survey follow up created successfully");
toast.success(t("environments.surveys.edit.follow_ups_modal_created_successfull_toast"));
setOpen(false);
form.reset();
setLocalSurvey((prev) => {

View File

@@ -1,3 +1,6 @@
import { type Response } from "@prisma/client";
import { notFound } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
IMPRINT_URL,
IS_FORMBRICKS_CLOUD,
@@ -16,9 +19,6 @@ import { PinScreen } from "@/modules/survey/link/components/pin-screen";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { type Response } from "@prisma/client";
import { notFound } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyRendererProps {
survey: TSurvey;
@@ -59,7 +59,7 @@ export const renderSurvey = async ({
const isSpamProtectionEnabled = Boolean(IS_RECAPTCHA_CONFIGURED && survey.recaptcha?.enabled);
if (survey.status !== "inProgress" && !isPreview) {
if (survey.status !== "inProgress") {
const project = await getProjectByEnvironmentId(survey.environmentId);
return (
<SurveyInactive

View File

@@ -5,7 +5,6 @@ import { Session } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
import { TTemplateRole } from "@formbricks/types/templates";
import { SurveysPage } from "./page";
// Mock all dependencies
@@ -53,19 +52,16 @@ vi.mock("@/modules/survey/list/lib/survey", () => ({
}));
vi.mock("@/modules/survey/templates/components/template-container", () => ({
TemplateContainerWithPreview: vi.fn(
({ userId, environment, project, prefilledFilters, isTemplatePage }) => (
<div
data-testid="template-container"
data-user-id={userId}
data-environment-id={environment.id}
data-project-id={project.id}
data-prefilled-filters={JSON.stringify(prefilledFilters)}
data-is-template-page={isTemplatePage}>
Template Container
</div>
)
),
TemplateContainerWithPreview: vi.fn(({ userId, environment, project, isTemplatePage }) => (
<div
data-testid="template-container"
data-user-id={userId}
data-environment-id={environment.id}
data-project-id={project.id}
data-is-template-page={isTemplatePage}>
Template Container
</div>
)),
}));
vi.mock("@/modules/ui/components/button", () => ({
@@ -207,9 +203,8 @@ describe("SurveysPage", () => {
mockTranslate.mockReturnValue("Project not found");
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
await expect(SurveysPage({ params, searchParams })).rejects.toThrow("Project not found");
await expect(SurveysPage({ params })).rejects.toThrow("Project not found");
expect(mockGetProjectWithTeamIdsByEnvironmentId).toHaveBeenCalledWith("env-123");
expect(mockTranslate).toHaveBeenCalledWith("common.project_not_found");
@@ -225,9 +220,8 @@ describe("SurveysPage", () => {
});
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
await SurveysPage({ params, searchParams });
await SurveysPage({ params });
expect(mockRedirect).toHaveBeenCalledWith("/environments/env-123/settings/billing");
});
@@ -236,9 +230,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(0);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({ role: "product_manager" as TTemplateRole });
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("template-container")).toBeInTheDocument();
@@ -246,20 +239,14 @@ describe("SurveysPage", () => {
expect(screen.getByTestId("template-container")).toHaveAttribute("data-environment-id", "env-123");
expect(screen.getByTestId("template-container")).toHaveAttribute("data-project-id", "project-123");
expect(screen.getByTestId("template-container")).toHaveAttribute("data-is-template-page", "false");
const prefilledFilters = JSON.parse(
screen.getByTestId("template-container").getAttribute("data-prefilled-filters") || "[]"
);
expect(prefilledFilters).toEqual(["website", "other", "product_manager"]);
});
test("renders surveys list when survey count is greater than 0", async () => {
mockGetSurveyCount.mockResolvedValue(5);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
@@ -289,9 +276,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(5);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("page-header")).toBeInTheDocument();
@@ -307,9 +293,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(0);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
// When survey count is 0, it should render TemplateContainer regardless of read-only status
@@ -330,16 +315,11 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(0);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("template-container")).toBeInTheDocument();
const prefilledFilters = JSON.parse(
screen.getByTestId("template-container").getAttribute("data-prefilled-filters") || "[]"
);
expect(prefilledFilters).toEqual([null, null, null]);
});
test("handles project with null styling", async () => {
@@ -351,9 +331,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(0);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("template-container")).toBeInTheDocument();
@@ -365,9 +344,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(5);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("surveys-list")).toHaveAttribute("data-locale", "en-US");
@@ -377,9 +355,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(5);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("link")).toHaveAttribute("href", "/environments/env-123/surveys/templates");

View File

@@ -14,7 +14,6 @@ import { PlusIcon } from "lucide-react";
import { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { TTemplateRole } from "@formbricks/types/templates";
export const metadata: Metadata = {
title: "Your Surveys",
@@ -24,17 +23,10 @@ interface SurveyTemplateProps {
params: Promise<{
environmentId: string;
}>;
searchParams: Promise<{
role?: TTemplateRole;
}>;
}
export const SurveysPage = async ({
params: paramsProps,
searchParams: searchParamsProps,
}: SurveyTemplateProps) => {
export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps) => {
const publicDomain = getPublicDomain();
const searchParams = await searchParamsProps;
const params = await paramsProps;
const t = await getTranslate();
@@ -46,8 +38,6 @@ export const SurveysPage = async ({
const { session, isBilling, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const prefilledFilters = [project?.config.channel, project.config.industry, searchParams.role ?? null];
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
@@ -79,7 +69,6 @@ export const SurveysPage = async ({
userId={session.user.id}
environment={environment}
project={projectWithRequiredProps}
prefilledFilters={prefilledFilters}
isTemplatePage={false}
/>
);

View File

@@ -3,7 +3,6 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
import { TTemplateRole } from "@formbricks/types/templates";
import { TemplateContainerWithPreview } from "./template-container";
// Mock dependencies
@@ -59,8 +58,6 @@ const mockEnvironment = {
appSetupCompleted: true,
};
const mockPrefilledFilters: (TProjectConfigChannel | TProjectConfigIndustry | TTemplateRole | null)[] = [];
describe("TemplateContainerWithPreview", () => {
afterEach(() => {
cleanup();
@@ -72,7 +69,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -86,7 +82,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={false}
/>
);
@@ -100,7 +95,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -114,7 +108,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={false}
/>
);
@@ -128,7 +121,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -144,7 +136,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -158,7 +149,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -172,7 +162,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);

View File

@@ -5,19 +5,16 @@ import { TemplateList } from "@/modules/survey/components/template-list";
import { MenuBar } from "@/modules/survey/templates/components/menu-bar";
import { PreviewSurvey } from "@/modules/ui/components/preview-survey";
import { SearchBar } from "@/modules/ui/components/search-bar";
import { Project } from "@prisma/client";
import { Environment } from "@prisma/client";
import type { Environment, Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { useState } from "react";
import type { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
import type { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import type { TTemplate } from "@formbricks/types/templates";
import { getMinimalSurvey } from "../lib/minimal-survey";
type TemplateContainerWithPreviewProps = {
project: Project;
environment: Pick<Environment, "id" | "appSetupCompleted">;
userId: string;
prefilledFilters: (TProjectConfigChannel | TProjectConfigIndustry | TTemplateRole | null)[];
isTemplatePage?: boolean;
};
@@ -25,7 +22,6 @@ export const TemplateContainerWithPreview = ({
project,
environment,
userId,
prefilledFilters,
isTemplatePage = true,
}: TemplateContainerWithPreviewProps) => {
const { t } = useTranslate();
@@ -63,7 +59,6 @@ export const TemplateContainerWithPreview = ({
setActiveQuestionId(template.preset.questions[0].id);
setActiveTemplate(template);
}}
prefilledFilters={prefilledFilters}
/>
</div>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 md:flex md:flex-col">

View File

@@ -2,23 +2,15 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
import { getTranslate } from "@/tolgee/server";
import { redirect } from "next/navigation";
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
import { TTemplateRole } from "@formbricks/types/templates";
import { TemplateContainerWithPreview } from "./components/template-container";
interface SurveyTemplateProps {
params: Promise<{
environmentId: string;
}>;
searchParams: Promise<{
channel?: TProjectConfigChannel;
industry?: TProjectConfigIndustry;
role?: TTemplateRole;
}>;
}
export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
const searchParams = await props.searchParams;
const t = await getTranslate();
const params = await props.params;
const environmentId = params.environmentId;
@@ -35,14 +27,7 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
return redirect(`/environments/${environment.id}/surveys`);
}
const prefilledFilters = [project.config.channel, project.config.industry, searchParams.role ?? null];
return (
<TemplateContainerWithPreview
userId={session.user.id}
environment={environment}
project={project}
prefilledFilters={prefilledFilters}
/>
<TemplateContainerWithPreview userId={session.user.id} environment={environment} project={project} />
);
};

View File

@@ -164,7 +164,7 @@ const nextConfig = {
},
{
key: "Content-Security-Policy",
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https://*.intercom.io https://*.intercomcdn.com https:; style-src 'self' 'unsafe-inline' https://*.intercomcdn.com https:; img-src 'self' blob: data: https://*.intercom.io https://*.intercomcdn.com https:; font-src 'self' data: https://*.intercomcdn.com https:; connect-src 'self' http://localhost:9000 https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https:; frame-src 'self' https://*.intercom.io https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https://*.intercom.io https://*.intercomcdn.com https:; style-src 'self' 'unsafe-inline' https://*.intercomcdn.com https:; img-src 'self' blob: data: http://localhost:9000 https://*.intercom.io https://*.intercomcdn.com https:; font-src 'self' data: https://*.intercomcdn.com https:; connect-src 'self' http://localhost:9000 https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https:; frame-src 'self' https://*.intercom.io https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
},
{
key: "Strict-Transport-Security",

View File

@@ -3,6 +3,7 @@ export const SURVEYS_API_URL = `/api/v1/management/surveys`;
export const WEBHOOKS_API_URL = `/api/v2/management/webhooks`;
export const ROLES_API_URL = `/api/v2/roles`;
export const ME_API_URL = `/api/v2/me`;
export const HEALTH_API_URL = `/api/v2/health`;
export const TEAMS_API_URL = (organizationId: string) => `/api/v2/organizations/${organizationId}/teams`;
export const PROJECT_TEAMS_API_URL = (organizationId: string) =>

View File

@@ -0,0 +1,135 @@
import { expect } from "@playwright/test";
import { logger } from "@formbricks/logger";
import { test } from "../lib/fixtures";
import { HEALTH_API_URL } from "./constants";
test.describe("API Tests for Health Endpoint", () => {
test("Health check returns 200 with dependency status", async ({ request }) => {
try {
// Make request to health endpoint (no authentication required)
const response = await request.get(HEALTH_API_URL);
// Should always return 200 if the health check endpoint can execute
expect(response.status()).toBe(200);
const responseBody = await response.json();
// Verify response structure
expect(responseBody).toHaveProperty("data");
expect(responseBody.data).toHaveProperty("main_database");
expect(responseBody.data).toHaveProperty("cache_database");
// Verify data types are boolean
expect(typeof responseBody.data.main_database).toBe("boolean");
expect(typeof responseBody.data.cache_database).toBe("boolean");
// Log the health status for debugging
logger.info(
{
main_database: responseBody.data.main_database,
cache_database: responseBody.data.cache_database,
},
"Health check status"
);
// In a healthy system, we expect both to be true
// But we don't fail the test if they're false - that's what the health check is for
if (!responseBody.data.main_database) {
logger.warn("Main database is unhealthy");
}
if (!responseBody.data.cache_database) {
logger.warn("Cache database is unhealthy");
}
} catch (error) {
logger.error(error, "Error during health check API test");
throw error;
}
});
test("Health check response time is reasonable", async ({ request }) => {
try {
const startTime = Date.now();
const response = await request.get(HEALTH_API_URL);
const endTime = Date.now();
const responseTime = endTime - startTime;
expect(response.status()).toBe(200);
// Health check should respond within 5 seconds
expect(responseTime).toBeLessThan(5000);
logger.info({ responseTime }, "Health check response time");
} catch (error) {
logger.error(error, "Error during health check performance test");
throw error;
}
});
test("Health check is accessible without authentication", async ({ request }) => {
try {
// Make request without any headers or authentication
const response = await request.get(HEALTH_API_URL, {
headers: {
// Explicitly no x-api-key or other auth headers
},
});
// Should be accessible without authentication
expect(response.status()).toBe(200);
const responseBody = await response.json();
expect(responseBody).toHaveProperty("data");
} catch (error) {
logger.error(error, "Error during unauthenticated health check test");
throw error;
}
});
test("Health check handles CORS properly", async ({ request }) => {
try {
// Test with OPTIONS request (preflight)
const optionsResponse = await request.fetch(HEALTH_API_URL, {
method: "OPTIONS",
});
// OPTIONS should succeed or at least not be a server error
expect(optionsResponse.status()).not.toBe(500);
// Test regular GET request
const getResponse = await request.get(HEALTH_API_URL);
expect(getResponse.status()).toBe(200);
} catch (error) {
logger.error(error, "Error during CORS health check test");
throw error;
}
});
test("Health check OpenAPI schema compliance", async ({ request }) => {
try {
const response = await request.get(HEALTH_API_URL);
expect(response.status()).toBe(200);
const responseBody = await response.json();
// Verify it matches our OpenAPI schema
expect(responseBody).toMatchObject({
data: {
main_database: expect.any(Boolean),
cache_database: expect.any(Boolean),
},
});
// Ensure no extra properties in the response data
const dataKeys = Object.keys(responseBody.data);
expect(dataKeys).toHaveLength(2);
expect(dataKeys).toContain("main_database");
expect(dataKeys).toContain("cache_database");
} catch (error) {
logger.error(error, "Error during OpenAPI schema compliance test");
throw error;
}
});
});

View File

@@ -15,7 +15,7 @@ Before you proceed, make sure you have the following:
Copy and paste the following command into your terminal:
```bash
/bin/sh -c "$(curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/main/docker/formbricks.sh)"
/bin/sh -c "$(curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/formbricks.sh)"
```
The script will prompt you for the following information:

View File

@@ -229,12 +229,8 @@ services:
- ./saml-connection:/home/nextjs/apps/web/saml-connection
<<: *environment
volumes:
postgres:
driver: local
redis:
driver: local
uploads:
driver: local

View File

@@ -293,7 +293,7 @@ EOT
minio_service_user="formbricks-service-$(openssl rand -hex 4)"
minio_service_password=$(openssl rand -base64 20)
minio_bucket_name="formbricks-uploads"
minio_policy_name="formbricks-policy-$(openssl rand -hex 4)"
minio_policy_name="formbricks-policy"
echo "✅ MinIO will be configured with:"
echo " S3 Access Key (least privilege): $minio_service_user"
@@ -306,7 +306,7 @@ EOT
fi
echo "📥 Downloading docker-compose.yml from Formbricks GitHub repository..."
curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/main/docker/docker-compose.yml
curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/docker-compose.yml
echo "🚙 Updating docker-compose.yml with your custom inputs..."
sed -i "/WEBAPP_URL:/s|WEBAPP_URL:.*|WEBAPP_URL: \"https://$domain_name\"|" docker-compose.yml
@@ -340,9 +340,11 @@ EOT
sed -i "s|# S3_BUCKET_NAME:|S3_BUCKET_NAME: \"$ext_s3_bucket\"|" docker-compose.yml
if [[ -n $ext_s3_endpoint ]]; then
sed -i "s|# S3_ENDPOINT_URL:|S3_ENDPOINT_URL: \"$ext_s3_endpoint\"|" docker-compose.yml
sed -i "s|S3_FORCE_PATH_STYLE: 0|S3_FORCE_PATH_STYLE: 1|" docker-compose.yml
# Ensure S3_FORCE_PATH_STYLE is enabled for S3-compatible endpoints
sed -E -i 's|^([[:space:]]*)#?[[:space:]]*S3_FORCE_PATH_STYLE:[[:space:]]*.*$|\1S3_FORCE_PATH_STYLE: 1|' docker-compose.yml
else
sed -i "s|S3_FORCE_PATH_STYLE: 0|# S3_FORCE_PATH_STYLE:|" docker-compose.yml
# Comment out S3_FORCE_PATH_STYLE for native AWS S3
sed -E -i 's|^([[:space:]]*)#?[[:space:]]*S3_FORCE_PATH_STYLE:[[:space:]]*.*$|\1# S3_FORCE_PATH_STYLE:|' docker-compose.yml
fi
echo "🚗 External S3 configuration updated successfully!"
elif [[ $minio_storage == "y" ]]; then
@@ -356,7 +358,8 @@ EOT
else
sed -i "s|# S3_ENDPOINT_URL:|S3_ENDPOINT_URL: \"http://$files_domain\"|" docker-compose.yml
fi
sed -i "s|S3_FORCE_PATH_STYLE: 0|S3_FORCE_PATH_STYLE: 1|" docker-compose.yml
# Ensure S3_FORCE_PATH_STYLE is enabled for MinIO
sed -E -i 's|^([[:space:]]*)#?[[:space:]]*S3_FORCE_PATH_STYLE:[[:space:]]*.*$|\1S3_FORCE_PATH_STYLE: 1|' docker-compose.yml
echo "🚗 MinIO S3 configuration updated successfully!"
fi
@@ -391,11 +394,34 @@ EOT
{ print }
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
# Step 2: Add minio-init dependency to formbricks if MinIO enabled
# Step 2: Ensure formbricks waits for minio-init to complete successfully (mapping depends_on)
if [[ $minio_storage == "y" ]]; then
sed -i '/formbricks:/,/depends_on:/{
/- postgres/a\ - minio-init
}' docker-compose.yml
# Remove any existing simple depends_on list and replace with mapping
awk '
BEGIN{in_fb=0; removing=0}
/^ formbricks:/ {in_fb=1}
in_fb && /^ depends_on:/ {removing=1; next}
in_fb && removing && /^ [A-Za-z0-9_-]+:/ {removing=0}
/^ [A-Za-z0-9_-]+:/ && !/^ formbricks:/ {in_fb=0}
{ if(!removing) print }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
awk '
BEGIN{in_fb=0; inserted=0}
/^ formbricks:/ {in_fb=1}
/^ [A-Za-z0-9_-]+:/ && !/^ formbricks:/ {in_fb=0}
{
print
if (in_fb && !inserted && $0 ~ /^ image:/) {
print " depends_on:"
print " postgres:"
print " condition: service_started"
print " minio-init:"
print " condition: service_completed_successfully"
inserted=1
}
}
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
fi
# Step 3: Build service snippets and inject them BEFORE the volumes section (robust, no sed -i multiline)
@@ -407,18 +433,13 @@ EOT
minio:
restart: always
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
image: minio/minio@sha256:13582eff79c6605a2d315bdd0e70164142ea7e98fc8411e9e10d089502a6d883
command: server /data
environment:
MINIO_ROOT_USER: "$minio_root_user"
MINIO_ROOT_PASSWORD: "$minio_root_password"
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
labels:
- "traefik.enable=true"
# S3 API on files subdomain
@@ -438,50 +459,18 @@ EOT
- "traefik.http.middlewares.minio-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.minio-ratelimit.ratelimit.burst=200"
minio-init:
image: minio/mc:latest
image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868
depends_on:
minio:
condition: service_healthy
- minio
environment:
MINIO_ROOT_USER: "$minio_root_user"
MINIO_ROOT_PASSWORD: "$minio_root_password"
MINIO_SERVICE_USER: "$minio_service_user"
MINIO_SERVICE_PASSWORD: "$minio_service_password"
MINIO_BUCKET_NAME: "$minio_bucket_name"
entrypoint:
- /bin/sh
- -c
- |
echo '🔗 Setting up MinIO alias...';
mc alias set minio http://minio:9000 "$minio_root_user" "$minio_root_password";
echo '🪣 Creating bucket (idempotent)...';
mc mb minio/$minio_bucket_name --ignore-existing;
echo '📄 Creating JSON policy file...';
printf '%s' "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:DeleteObject\",\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::$minio_bucket_name/*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:ListBucket\"],\"Resource\":[\"arn:aws:s3:::$minio_bucket_name\"]}]}" > /tmp/formbricks-policy.json
echo '🔒 Creating policy (idempotent)...';
if ! mc admin policy info minio $minio_policy_name >/dev/null 2>&1; then
mc admin policy create minio $minio_policy_name /tmp/formbricks-policy.json || mc admin policy add minio $minio_policy_name /tmp/formbricks-policy.json;
echo 'Policy created successfully.';
else
echo 'Policy already exists, skipping creation.';
fi
echo '👤 Creating service user (idempotent)...';
if ! mc admin user info minio "$minio_service_user" >/dev/null 2>&1; then
mc admin user add minio "$minio_service_user" "$minio_service_password";
echo 'User created successfully.';
else
echo 'User already exists, skipping creation.';
fi
echo '🔗 Attaching policy to user (idempotent)...';
mc admin policy attach minio $minio_policy_name --user "$minio_service_user" || echo 'Policy already attached or attachment failed (non-fatal).';
echo '✅ MinIO setup complete!';
exit 0;
entrypoint: ["/bin/sh", "/tmp/minio-init.sh"]
volumes:
- ./minio-init.sh:/tmp/minio-init.sh:ro
traefik:
image: "traefik:v2.7"
@@ -559,11 +548,175 @@ EOF
{ if (!skip) print }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
# Create minio-init script outside heredoc to avoid variable expansion issues
if [[ $minio_storage == "y" ]]; then
cat > minio-init.sh << 'MINIO_SCRIPT_EOF'
#!/bin/sh
echo '⏳ Waiting for MinIO to be ready...'
attempts=0
max_attempts=30
until mc alias set minio http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null 2>&1 \
&& mc ls minio >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ $attempts -ge $max_attempts ]; then
printf '❌ Failed to connect to MinIO after %s attempts\n' $max_attempts
exit 1
fi
printf '...still waiting attempt %s/%s\n' $attempts $max_attempts
sleep 2
done
echo '🔗 MinIO reachable; alias configured.'
echo '🪣 Creating bucket (idempotent)...';
mc mb minio/$MINIO_BUCKET_NAME --ignore-existing;
echo '📄 Creating JSON policy file...';
cat > /tmp/formbricks-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"],
"Resource": ["arn:aws:s3:::$MINIO_BUCKET_NAME/*"]
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::$MINIO_BUCKET_NAME"]
}
]
}
EOF
echo '🔒 Creating policy (idempotent)...';
if ! mc admin policy info minio formbricks-policy >/dev/null 2>&1; then
mc admin policy create minio formbricks-policy /tmp/formbricks-policy.json || mc admin policy add minio formbricks-policy /tmp/formbricks-policy.json;
echo 'Policy created successfully.';
else
echo 'Policy already exists, skipping creation.';
fi
echo '👤 Creating service user (idempotent)...';
if ! mc admin user info minio "$MINIO_SERVICE_USER" >/dev/null 2>&1; then
mc admin user add minio "$MINIO_SERVICE_USER" "$MINIO_SERVICE_PASSWORD";
echo 'User created successfully.';
else
echo 'User already exists, skipping creation.';
fi
echo '🔗 Attaching policy to user (idempotent)...';
mc admin policy attach minio formbricks-policy --user "$MINIO_SERVICE_USER" || echo 'Policy already attached or attachment failed (non-fatal).';
echo '✅ MinIO setup complete!';
exit 0;
MINIO_SCRIPT_EOF
chmod +x minio-init.sh
fi
newgrp docker <<END
docker compose up -d
if [[ $minio_storage == "y" ]]; then
echo " Waiting for MinIO to be ready..."
attempts=0
max_attempts=30
until docker run --rm --network $(basename "$PWD")_default --entrypoint /bin/sh \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc \
"mc alias set minio http://minio:9000 '$minio_root_user' '$minio_root_password' >/dev/null 2>&1 && mc admin info minio >/dev/null 2>&1"; do
attempts=$((attempts+1))
if [ $attempts -ge $max_attempts ]; then
echo "❌ MinIO did not become ready in time. Proceeding, but subsequent steps may fail."
break
fi
echo "...attempt $attempts/$max_attempts"
sleep 5
done
echo " Ensuring bucket exists..."
docker run --rm --network $(basename "$PWD")_default \
-e MINIO_ROOT_USER="$minio_root_user" \
-e MINIO_ROOT_PASSWORD="$minio_root_password" \
-e MINIO_BUCKET_NAME="$minio_bucket_name" \
--entrypoint /bin/sh \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc '
mc alias set minio http://minio:9000 "$minio_root_user" "$minio_root_password" >/dev/null 2>&1;
mc mb minio/"$minio_bucket_name" --ignore-existing
'
echo " Ensuring service user and policy exist (idempotent)..."
docker run --rm --network $(basename "$PWD")_default \
-e MINIO_ROOT_USER="$minio_root_user" \
-e MINIO_ROOT_PASSWORD="$minio_root_password" \
-e MINIO_SERVICE_USER="$minio_service_user" \
-e MINIO_SERVICE_PASSWORD="$minio_service_password" \
-e MINIO_BUCKET_NAME="$minio_bucket_name" \
--entrypoint /bin/sh \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc '
mc alias set minio http://minio:9000 "$minio_root_user" "$minio_root_password" >/dev/null 2>&1;
if ! mc admin policy info minio formbricks-policy >/dev/null 2>&1; then
cat > /tmp/formbricks-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"], "Resource": ["arn:aws:s3:::$minio_bucket_name/*"] },
{ "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::$minio_bucket_name"] }
]
}
EOF
mc admin policy create minio formbricks-policy /tmp/formbricks-policy.json >/dev/null 2>&1 || true
fi;
if ! mc admin user info minio "$minio_service_user" >/dev/null 2>&1; then
mc admin user add minio "$minio_service_user" "$minio_service_password" >/dev/null 2>&1 || true
fi;
mc admin policy attach minio formbricks-policy --user "$minio_service_user" >/dev/null 2>&1 || true
'
fi
if [[ $minio_storage == "y" ]]; then
echo "⏳ Finalizing MinIO setup..."
attempts=0; max_attempts=60
while cid=$(docker compose ps -q minio-init 2>/dev/null); do
status=$(docker inspect -f '{{.State.Status}}' "$cid" 2>/dev/null || echo "")
if [ "$status" = "exited" ] || [ -z "$status" ]; then
break
fi
attempts=$((attempts+1))
if [ $attempts -ge $max_attempts ]; then
echo "⚠️ minio-init still running after wait; proceeding with cleanup anyway."
break
fi
sleep 2
done
echo "🧹 Cleaning up minio-init service and references..."
awk '
BEGIN{skip=0}
/^services:[[:space:]]*$/ { print; next }
/^ minio-init:/ { skip=1; next }
/^ [A-Za-z0-9_-]+:/ { if (skip) skip=0 }
{ if (!skip) print }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
# Remove list-style "- minio-init" lines under depends_on (if any)
sed -E -i '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
# Remove the minio-init mapping and its condition line
sed -i '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
# Remove any stopped minio-init container and restart without orphans
docker compose rm -f -s minio-init >/dev/null 2>&1 || true
docker compose up -d --remove-orphans
# Clean up the temporary minio-init script
rm -f minio-init.sh
echo "✅ MinIO one-time init cleaned up."
fi
echo "🔗 To edit more variables and deeper config, go to the formbricks/docker-compose.yml, edit the file, and restart the container!"
echo "🚨 Make sure you have set up the DNS records as well as inbound rules for the domain name and IP address of this instance."

1472
docker/migrate-to-v4.sh Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -5675,7 +5675,7 @@
},
"/api/v1/management/storage": {
"post": {
"description": "API endpoint for uploading public files. Uploaded files are public and accessible by anyone. This endpoint requires authentication. It accepts a JSON body with fileName, fileType, environmentId, and optionally allowedFileExtensions to restrict file types. On success, it returns a signed URL for uploading the file to S3 along with a local upload URL.",
"description": "API endpoint for uploading public files. Uploaded files are public and accessible by anyone. This endpoint requires authentication. It accepts a JSON body with fileName, fileType, environmentId, and optionally allowedFileExtensions to restrict file types. On success, it returns a signed URL for uploading the file to S3.",
"parameters": [
{
"example": "{{apiKey}}",
@@ -5732,8 +5732,15 @@
"example": {
"data": {
"fileUrl": "http://localhost:3000/storage/cm1ubebtj000614kqe4hs3c67/public/profile--fid--abc123.png",
"localUrl": "http://localhost:3000/storage/cm1ubebtj000614kqe4hs3c67/public/profile.png",
"signedUrl": "http://localhost:3000/api/v1/client/cm1ubebtj000614kqe4hs3c67/storage/public",
"presignedFields": {
"Policy": "base64EncodedPolicy",
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
"X-Amz-Credential": "your-credential",
"X-Amz-Date": "20250312T000000Z",
"X-Amz-Signature": "your-signature",
"key": "uploads/public/profile--fid--abc123.png"
},
"signedUrl": "https://s3.example.com/your-bucket",
"updatedFileName": "profile--fid--abc123.png"
}
},
@@ -5745,9 +5752,12 @@
"description": "URL where the uploaded file can be accessed.",
"type": "string"
},
"localUrl": {
"description": "URL for uploading the file to local storage.",
"type": "string"
"presignedFields": {
"additionalProperties": {
"type": "string"
},
"description": "Form fields to include in the multipart/form-data POST to S3.",
"type": "object"
},
"signedUrl": {
"description": "Signed URL for uploading the file to S3.",
@@ -5765,7 +5775,7 @@
}
}
},
"description": "OK - Returns the signed URL, updated file name, and file URL."
"description": "OK - Returns the signed URL, presigned fields, updated file name, and file URL."
},
"400": {
"content": {
@@ -5829,187 +5839,6 @@
"tags": ["Management API - Storage"]
}
},
"/api/v1/management/storage/local": {
"post": {
"description": "Management API endpoint for uploading public files to local storage. This endpoint requires authentication. File metadata is provided via headers (X-File-Type, X-File-Name, X-Environment-ID, X-Signature, X-UUID, X-Timestamp) and the file is provided as a multipart/form-data file field named \"file\". The \"Content-Type\" header must be set to a valid MIME type.",
"parameters": [
{
"example": "{{apiKey}}",
"in": "header",
"name": "x-api-key",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "MIME type of the file. Must be a valid MIME type.",
"in": "header",
"name": "X-File-Type",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "URI encoded file name.",
"in": "header",
"name": "X-File-Name",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "ID of the environment.",
"in": "header",
"name": "X-Environment-ID",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Signature for verifying the request.",
"in": "header",
"name": "X-Signature",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Unique identifier for the signed upload.",
"in": "header",
"name": "X-UUID",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Timestamp used for the signature.",
"in": "header",
"name": "X-Timestamp",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"properties": {
"file": {
"description": "The file to be uploaded as a valid file object (buffer).",
"format": "binary",
"type": "string"
}
},
"required": ["file"],
"type": "object"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"data": {
"message": "File uploaded successfully"
}
},
"schema": {
"properties": {
"data": {
"properties": {
"message": {
"description": "Success message.",
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
}
}
},
"description": "OK - File uploaded successfully."
},
"400": {
"content": {
"application/json": {
"example": {
"error": "fileType is required"
},
"schema": {
"properties": {
"error": {
"description": "Detailed error message.",
"type": "string"
}
},
"type": "object"
}
}
},
"description": "Bad Request - Missing required fields, invalid header values, or file issues."
},
"401": {
"content": {
"application/json": {
"example": {
"error": "Not authenticated"
},
"schema": {
"properties": {
"error": {
"description": "Detailed error message.",
"type": "string"
}
},
"type": "object"
}
}
},
"description": "Unauthorized - Authentication failed, invalid signature, or user not authorized."
},
"500": {
"content": {
"application/json": {
"example": {
"error": "File upload failed"
},
"schema": {
"properties": {
"error": {
"description": "Detailed error message.",
"type": "string"
}
},
"type": "object"
}
}
},
"description": "Internal Server Error - File upload failed due to server error."
}
},
"servers": [
{
"description": "Formbricks API Server",
"url": "https://app.formbricks.com/api/v1"
}
],
"summary": "Upload Public File to Local Storage",
"tags": ["Management API - Storage"]
}
},
"/api/v1/management/surveys": {
"get": {
"description": "Fetches all existing surveys",

View File

@@ -7,6 +7,8 @@ servers:
- url: https://app.formbricks.com/api/v2
description: Formbricks Cloud
tags:
- name: Health
description: Operations for checking critical application dependencies health status.
- name: Roles
description: Operations for managing roles.
- name: Me
@@ -391,6 +393,36 @@ paths:
servers:
- url: https://app.formbricks.com/api/v2
description: Formbricks API Server
/health:
get:
tags:
- Health
summary: Health Check
description: Check the health status of critical application dependencies
including database and cache.
operationId: healthCheck
security: []
responses:
"200":
description: Health check completed successfully. Check individual dependency
status in response data.
content:
application/json:
schema:
type: object
properties:
main_database:
type: boolean
description: Main database connection status - true if database is reachable and
running
example: true
cache_database:
type: boolean
description: Cache database connection status - true if cache database is
reachable and running
example: true
title: Health Check Response
description: Health check status for critical application dependencies
/roles:
get:
operationId: getRoles
@@ -3500,6 +3532,24 @@ components:
name: x-api-key
description: Use your Formbricks x-api-key to authenticate.
schemas:
health:
type: object
properties:
main_database:
type: boolean
description: Main database connection status - true if database is reachable and
running
example: true
cache_database:
type: boolean
description: Cache database connection status - true if cache database is
reachable and running
example: true
required:
- main_database
- cache_database
title: Health Check Response
description: Health check status for critical application dependencies
role:
type: object
properties:
@@ -3835,8 +3885,6 @@ components:
type: string
enum:
- link
- web
- website
- app
description: The type of the survey
status:
@@ -4346,7 +4394,6 @@ components:
- createdBy
- environmentId
- endings
- thankYouCard
- hiddenFields
- variables
- displayOption
@@ -4364,7 +4411,6 @@ components:
- isSingleResponsePerEmailEnabled
- inlineTriggers
- isBackButtonHidden
- verifyEmail
- recaptcha
- metadata
- displayPercentage

View File

@@ -5,6 +5,11 @@
"light": "#00C4B8",
"primary": "#00C4B8"
},
"errors": {
"404": {
"redirect": true
}
},
"favicon": "/images/favicon.svg",
"footer": {
"socials": {
@@ -69,7 +74,6 @@
"xm-and-surveys/surveys/general-features/multi-language-surveys",
"xm-and-surveys/surveys/general-features/partial-submissions",
"xm-and-surveys/surveys/general-features/recall",
"xm-and-surveys/surveys/general-features/schedule-start-end-dates",
"xm-and-surveys/surveys/general-features/metadata",
"xm-and-surveys/surveys/general-features/variables",
"xm-and-surveys/surveys/general-features/hide-back-button",
@@ -225,6 +229,7 @@
"self-hosting/configuration/custom-ssl",
"self-hosting/configuration/environment-variables",
"self-hosting/configuration/smtp",
"self-hosting/configuration/file-uploads",
"self-hosting/configuration/domain-configuration",
{
"group": "Auth & SSO",
@@ -393,447 +398,358 @@
"redirects": [
{
"destination": "/docs/overview/what-is-formbricks",
"permanent": true,
"source": "/docs/introduction/what-is-formbricks"
},
{
"destination": "/docs/overview/open-source",
"permanent": true,
"source": "/docs/introduction/why-open-source"
},
{
"destination": "/docs/xm-and-surveys/overview",
"permanent": true,
"source": "/docs/introduction/how-it-works"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/contact-form",
"permanent": true,
"source": "/docs/best-practices/contact-form"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/docs-feedback",
"permanent": true,
"source": "/docs/best-practices/docs-feedback"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/feature-chaser",
"permanent": true,
"source": "/docs/best-practices/feature-chaser"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/feedback-box",
"permanent": true,
"source": "/docs/best-practices/feedback-box"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/improve-email-content",
"permanent": true,
"source": "/docs/best-practices/improve-email-content"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/interview-prompt",
"permanent": true,
"source": "/docs/best-practices/interview-prompt"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/cancel-subscription",
"permanent": true,
"source": "/docs/best-practices/cancel-subscription"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/pmf-survey",
"permanent": true,
"source": "/docs/best-practices/pmf-survey"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/quiz-time",
"permanent": true,
"source": "/docs/best-practices/quiz-time"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/improve-trial-cr",
"permanent": true,
"source": "/docs/best-practices/improve-trial-cr"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/quickstart",
"permanent": true,
"source": "/docs/link-surveys/quickstart"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/add-image-or-video-question",
"permanent": true,
"source": "/docs/link-surveys/global/add-image-or-video-question"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/conditional-logic",
"permanent": true,
"source": "/docs/link-surveys/global/conditional-logic"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/overwrite-styling",
"permanent": true,
"source": "/docs/link-surveys/global/overwrite-styling"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/data-prefilling",
"permanent": true,
"source": "/docs/link-surveys/global/data-prefilling"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/embed-surveys",
"permanent": true,
"source": "/docs/link-surveys/embed-surveys"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/hidden-fields",
"permanent": true,
"source": "/docs/link-surveys/global/hidden-fields"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/limit-submissions",
"permanent": true,
"source": "/docs/link-surveys/global/limit-submissions"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/market-research-panel",
"permanent": true,
"source": "/docs/link-surveys/market-research-panel"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/multi-language-surveys",
"permanent": true,
"source": "/docs/link-surveys/global/multi-language-surveys"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/partial-submissions",
"permanent": true,
"source": "/docs/link-surveys/global/partial-submissions"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/pin-protected-surveys",
"permanent": true,
"source": "/docs/link-surveys/pin-protected-surveys"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/recall",
"permanent": true,
"source": "/docs/link-surveys/global/recall"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/single-use-links",
"permanent": true,
"source": "/docs/link-surveys/single-use-links"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/source-tracking",
"permanent": true,
"source": "/docs/link-surveys/source-tracking"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/schedule-start-end-dates",
"permanent": true,
"source": "/docs/link-surveys/global/schedule-start-end-dates"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
"permanent": true,
"source": "/docs/link-surveys/start-at-question"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/metadata",
"permanent": true,
"source": "/docs/link-surveys/global/metadata"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/variables",
"permanent": true,
"source": "/docs/link-surveys/global/variables"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/verify-email-before-survey",
"permanent": true,
"source": "/docs/link-surveys/verify-email-before-survey"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/add-image-or-video-question",
"permanent": true,
"source": "/docs/app-surveys/global/add-image-or-video-question"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/consent",
"permanent": true,
"source": "/docs/core-features/global/question-type/consent"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/statement-cta",
"permanent": true,
"source": "/docs/core-features/global/question-type/statement-cta"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/airtable",
"permanent": true,
"source": "/docs/developer-docs/integrations/airtable"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/zapier",
"permanent": true,
"source": "/docs/developer-docs/integrations/zapier"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/wordpress",
"permanent": true,
"source": "/docs/developer-docs/integrations/wordpress"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/slack",
"permanent": true,
"source": "/docs/developer-docs/integrations/slack"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/n8n",
"permanent": true,
"source": "/docs/developer-docs/integrations/n8n"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/notion",
"permanent": true,
"source": "/docs/developer-docs/integrations/notion"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/google-sheets",
"permanent": true,
"source": "/docs/developer-docs/integrations/google-sheets"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/quickstart",
"permanent": true,
"source": "/docs/app-surveys/quickstart"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/address",
"permanent": true,
"source": "/docs/core-features/global/question-type/address"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides",
"permanent": true,
"source": "/docs/app-surveys/framework-guides"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/activepieces",
"permanent": true,
"source": "/docs/developer-docs/integrations/activepieces"
},
{
"destination": "/docs/xm-and-surveys/core-features/user-management",
"permanent": true,
"source": "/docs/core-features/global/access-roles"
},
{
"destination": "/docs/xm-and-surveys/core-features/styling-theme",
"permanent": true,
"source": "/docs/core-features/global/styling-theme"
},
{
"destination": "/docs/xm-and-surveys/core-features/email-customization",
"permanent": true,
"source": "/docs/core-features/global/email-customization"
},
{
"destination": "/docs/self-hosting/setup/one-click",
"permanent": true,
"source": "/docs/self-hosting/one-click"
},
{
"destination": "/docs/self-hosting/configuration/custom-ssl",
"permanent": true,
"source": "/docs/self-hosting/custom-ssl"
},
{
"destination": "/docs/self-hosting/setup/docker",
"permanent": true,
"source": "/docs/self-hosting/docker"
},
{
"destination": "/docs/self-hosting/setup/cluster-setup",
"permanent": true,
"source": "/docs/self-hosting/cluster-setup"
},
{
"destination": "/docs/self-hosting/advanced/migration",
"permanent": true,
"source": "/docs/self-hosting/migration-guide"
},
{
"destination": "/docs/self-hosting/configuration/integrations",
"permanent": true,
"source": "/docs/self-hosting/integrations"
},
{
"destination": "/docs/self-hosting/advanced/license",
"permanent": true,
"source": "/docs/self-hosting/license"
},
{
"destination": "/docs/self-hosting/advanced/rate-limiting",
"permanent": true,
"source": "/docs/self-hosting/rate-limiting"
},
{
"destination": "/docs/self-hosting/setup/cluster-setup",
"permanent": true,
"source": "/docs/self-hosting/kubernetes"
},
{
"destination": "/docs/development/overview",
"permanent": true,
"source": "/docs/developer-docs/overview"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides",
"permanent": true,
"source": "/docs/developer-docs/js-sdk"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#react-native",
"permanent": true,
"source": "/docs/developer-docs/react-native-in-app-surveys"
},
{
"destination": "/docs/api-reference/rest-api",
"permanent": true,
"source": "/docs/developer-docs/rest-api"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/webhooks",
"permanent": true,
"source": "/docs/developer-docs/webhooks"
},
{
"destination": "/docs/development/contribution/contribution",
"permanent": true,
"source": "/docs/developer-docs/contributing/get-started"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/actions",
"permanent": true,
"source": "/docs/app-surveys/actions"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting",
"permanent": true,
"source": "/docs/app-surveys/advanced-targeting"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/user-identification",
"permanent": true,
"source": "/docs/app-surveys/user-identification"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/recontact",
"permanent": true,
"source": "/docs/app-surveys/recontact"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/show-survey-to-percent-of-users",
"permanent": true,
"source": "/docs/app-surveys/global/show-survey-to-percent-of-users"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/metadata",
"permanent": true,
"source": "/docs/app-surveys/global/metadata"
},
{
"destination": "/docs/api-reference",
"permanent": true,
"source": "/docs/api-docs"
},
{
"destination": "/docs/development/troubleshooting",
"permanent": true,
"source": "/docs/developer-docs/contributing/troubleshooting"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/file-upload",
"permanent": true,
"source": "/docs/core-features/global/question-type/file-upload"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/select-picture",
"permanent": true,
"source": "/docs/core-features/global/question-type/picture-selection"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/rating",
"permanent": true,
"source": "/docs/core-features/global/question-type/rating"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/date",
"permanent": true,
"source": "/docs/core-features/global/question-type/date"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/schedule-a-meeting",
"permanent": true,
"source": "/docs/core-features/global/question-type/schedule"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/free-text",
"permanent": true,
"source": "/docs/core-features/global/question-type/free-text"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/select-single",
"permanent": true,
"source": "/docs/core-features/global/question-type/single-select"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/select-multiple",
"permanent": true,
"source": "/docs/core-features/global/question-type/multiple-select"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/matrix",
"permanent": true,
"source": "/docs/core-features/global/question-type/matrix"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/make",
"permanent": true,
"source": "/docs/developer-docs/integrations/make"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/overview",
"permanent": true,
"source": "/docs/developer-docs/integrations/overview"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/hidden-fields",
"permanent": true,
"source": "/docs/app-surveys/global/hidden-fields"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/limit-submissions",
"permanent": true,
"source": "/docs/app-surveys/global/limit-submissions"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/net-promoter-score",
"permanent": true,
"source": "/docs/core-features/global/question-type/net-promoter-score"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/data-prefilling",
"permanent": true,
"source": "/docs/link-surveys/data-prefilling"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/multi-language-surveys",
"permanent": true,
"source": "/docs/app-surveys/global/multi-language-surveys"
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 119 KiB

View File

@@ -4,6 +4,152 @@ description: "Formbricks Self-hosted version migration"
icon: "arrow-right"
---
## v4.0
<Warning>
**Important: Migration Required**
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
</Warning>
Formbricks 4.0 is a **major milestone** that sets up the technical foundation for future iterations and feature improvements. This release focuses on modernizing core infrastructure components to improve reliability, scalability, and enable advanced features going forward.
### What's New in Formbricks 4.0
**🚀 New Enterprise Features:**
- **Quotas Management**: Advanced quota controls for enterprise users
**🏗️ Technical Foundation Improvements:**
- **Enhanced File Storage**: Improved file handling with better performance and reliability
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
- **Database Optimization**: Removal of unused database tables and fields for better performance
- **Future-Ready Architecture**: Standardized infrastructure components for upcoming features
### What This Means for Your Self-Hosting Setup
These improvements in Formbricks 4.0 also make some infrastructure requirements mandatory going forward:
- **Redis** for caching
- **MinIO or S3-compatible storage** for file uploads
These services are already included in the updated one-click setup for self-hosters, but existing users need to upgrade their setup. More information on this below.
### Why We Made These Changes
We know this represents more moving parts in your infrastructure and might even introduce more complexity in hosting Formbricks, and we don't take this decision lightly. As Formbricks grows into a comprehensive Survey and Experience Management platform, we've reached a point where the simple, single-service approach was holding back our ability to deliver the reliable, feature-rich product our users demand and deserve.
By moving to dedicated, professional-grade services for these critical functions, we're building the foundation needed to deliver:
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
- **Advanced features** that require sophisticated caching and file processing
- **Better performance** through optimized, dedicated services
- **Future scalability** to support larger deployments and more complex use cases without the need to maintain two different approaches
We believe this is the only path forward to build the comprehensive Survey and Experience Management software we're aiming for.
### Migration Steps for v4.0
Additional migration steps are needed if you are using a self-hosted Formbricks setup that uses either local file storage (not S3-compatible file storage) or doesn't already use a Redis cache.
### One-Click Setup
For users using our official one-click setup, we provide an automated migration using a migration script:
```bash
# Download the latest script
curl -fsSL -o migrate-to-v4.sh \
https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/migrate-to-v4.sh
# Make it executable
chmod +x migrate-to-v4.sh
# Launch the guided migration
./migrate-to-v4.sh
```
This script guides you through the steps for the infrastructure migration and does the following:
- Adds a Redis service to your setup and configures it
- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it
- Pulls the latest Formbricks image and updates your instance
### Manual Setup
If you use a different setup to host your Formbricks instance, you need to make sure to make the necessary adjustments to run Formbricks 4.0.
#### Redis
Formbricks 4.0 requires a Redis instance to work properly. Please add a Redis instance to your Docker setup, your K8s infrastructure, or however you are hosting Formbricks at the moment. Formbricks works with the latest versions of Redis as well as Valkey.
You need to configure the `REDIS_URL` environment variable and point it to your Redis instance.
#### S3-compatible storage
To use file storage (e.g., file upload questions, image choice questions, custom survey backgrounds, etc.), you need to have S3-compatible file storage set up and connected to Formbricks.
Formbricks supports multiple storage providers (among many other S3-compatible storages):
- AWS S3
- Digital Ocean Spaces
- Hetzner Object Storage
- Custom MinIO server
Please make sure to set up a storage bucket with one of these solutions and then link it to Formbricks using the following environment variables:
```
S3_ACCESS_KEY: your-access-key
S3_SECRET_KEY: your-secret-key
S3_REGION: us-east-1
S3_BUCKET_NAME: formbricks-uploads
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
```
#### Upgrade Process
**1. Backup your Database**
**Critical Step**: Create a complete database backup before proceeding. Formbricks 4.0 will automatically remove unused database tables and fields during startup.
```bash
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v4.0_$(date +%Y%m%d_%H%M%S).dump
```
<Info>
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
</Info>
**2. Upgrade to Formbricks 4.0**
Pull the latest Docker images and restart the setup (example for docker-compose):
```bash
# Pull the latest version
docker compose pull
# Stop the current instance
docker compose down
# Start with Formbricks 4.0
docker compose up -d
```
**3. Automatic Database Migration**
When you start Formbricks 4.0 for the first time, it will **automatically**:
- Detect and apply required database schema updates
- Remove unused database tables and fields
- Optimize the database structure for better performance
No manual intervention is required for the database migration.
**4. Verify Your Upgrade**
- Access your Formbricks instance at the same URL as before
- Test file uploads to ensure S3/MinIO integration works correctly
- Verify that existing surveys and data are intact
- Check that previously uploaded files are accessible
### v3.3
<Info>
@@ -185,7 +331,7 @@ This major release brings a better approach to **data migrations**.
### Steps to Migrate
This guide is for users **self-hosting** Formbricks with the **one-click setup**. If you're using a different setup, you may need to adjust the commands.
This guide is for users **self-hosting** Formbricks with the **one-click setup**. If you're using a different setup, you might adjust the commands.
- &#x20;Navigate to the Formbricks Directory

View File

@@ -0,0 +1,316 @@
---
title: "File Uploads Configuration"
description: "Configure file storage for survey images, file uploads, and project assets in your self-hosted Formbricks instance"
icon: "upload"
---
Formbricks requires S3-compatible storage for file uploads. You can use external cloud storage services or the bundled MinIO option for a self-hosted solution.
## Why Configure File Uploads?
Setting up file storage enables important features in Formbricks, including:
- Adding images to surveys (questions, backgrounds, logos)
- 'File Upload' and 'Picture Selection' question types
- Project logos and branding
- Custom organization logos in emails
- Survey background images from uploads
<Warning>
If file uploads are not configured, the above features will be disabled and users won't be able to upload
files or images.
</Warning>
## Storage Options
Formbricks supports S3-compatible storage with two main configurations:
### 1. External S3-Compatible Storage
Use cloud storage services for production deployments:
- **AWS S3** (Amazon Web Services)
- **DigitalOcean Spaces**
- **Backblaze B2**
- **Wasabi**
- **StorJ**
- Any S3-compatible storage service
### 2. Bundled MinIO Storage (Self-Hosted)
<Warning>
**Important**: MinIO requires a dedicated subdomain to function properly. You must configure a subdomain
like `files.yourdomain.com` that points to your server. MinIO will not work without this subdomain setup.
</Warning>
MinIO provides a self-hosted S3-compatible storage solution that runs alongside Formbricks. This option:
- Runs in a Docker container alongside Formbricks
- Provides full S3 API compatibility
- Requires minimal additional configuration
## Configuration Methods
### Option 1: One-Click Setup Script
When using the Formbricks installation script, you'll be prompted to configure file uploads:
```bash
📁 Do you want to configure file uploads?
If you skip this, the following features will be disabled:
- Adding images to surveys (e.g., in questions or as background)
- 'File Upload' and 'Picture Selection' question types
- Project logos
- Custom organization logo in emails
Configure file uploads now? [Y/n] y
```
#### External S3-Compatible Storage
Choose this option for AWS S3, DigitalOcean Spaces, or other cloud providers:
```bash
🗄️ Do you want to use an external S3-compatible storage (AWS S3/DO Spaces/etc.)? [y/N] y
🔧 Enter S3 configuration (leave Endpoint empty for AWS S3):
S3 Access Key: your_access_key
S3 Secret Key: your_secret_key
S3 Region (e.g., us-east-1): us-east-1
S3 Bucket Name: your-bucket-name
S3 Endpoint URL (leave empty if you are using AWS S3): https://your-endpoint.com
```
#### Bundled MinIO Storage
Choose this option for a self-hosted S3-compatible storage that runs alongside Formbricks:
<Note>
**Critical Requirement**: Before proceeding, ensure you have configured a subdomain (e.g.,
`files.yourdomain.com`) that points to your server's IP address. MinIO will not function without this
subdomain setup.
</Note>
```bash
🗄️ Do you want to use an external S3-compatible storage (AWS S3/DO Spaces/etc.)? [y/N] n
🔗 Enter the files subdomain for object storage (e.g., files.yourdomain.com): files.yourdomain.com
```
The script will automatically:
- Generate secure MinIO credentials
- Create the storage bucket
- Configure SSL certificates for the files subdomain
- Configure Traefik routing for the subdomain
### Option 2: Manual Environment Variables
Add the following environment variables to your `docker-compose.yml` or `.env` file:
#### For S3-Compatible Storage
```bash
# S3 Storage Configuration
S3_ACCESS_KEY=your_access_key
S3_SECRET_KEY=your_secret_key
S3_REGION=us-east-1
S3_BUCKET_NAME=your-bucket-name
# Optional: For third-party S3-compatible services (leave empty for AWS S3)
S3_ENDPOINT_URL=https://your-endpoint.com
# Enable path-style URLs for third-party services (1 for enabled, 0 for disabled)
S3_FORCE_PATH_STYLE=1
```
## Provider-Specific Examples
### AWS S3
```bash
S3_ACCESS_KEY=AKIA1234567890EXAMPLE
S3_SECRET_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
S3_REGION=us-east-1
S3_BUCKET_NAME=my-formbricks-uploads
# S3_ENDPOINT_URL is not needed for AWS S3
# S3_FORCE_PATH_STYLE=0
```
### DigitalOcean Spaces
```bash
S3_ACCESS_KEY=your_spaces_key
S3_SECRET_KEY=your_spaces_secret
S3_REGION=nyc3
S3_BUCKET_NAME=my-formbricks-space
S3_ENDPOINT_URL=https://nyc3.digitaloceanspaces.com
S3_FORCE_PATH_STYLE=1
```
### MinIO (Self-Hosted)
```bash
S3_ACCESS_KEY=minio_access_key
S3_SECRET_KEY=minio_secret_key
S3_REGION=us-east-1
S3_BUCKET_NAME=formbricks-uploads
S3_ENDPOINT_URL=https://files.yourdomain.com
S3_FORCE_PATH_STYLE=1
```
### Backblaze B2
```bash
S3_ACCESS_KEY=your_b2_key_id
S3_SECRET_KEY=your_b2_application_key
S3_REGION=us-west-000
S3_BUCKET_NAME=my-formbricks-bucket
S3_ENDPOINT_URL=https://s3.us-west-000.backblazeb2.com
S3_FORCE_PATH_STYLE=1
```
## Bundled MinIO Setup
When using the bundled MinIO option through the setup script, you get:
### Automatic Configuration
- **Storage Service**: MinIO running in a Docker container
- **Credentials**: Auto-generated secure access keys
- **Bucket**: Automatically created `formbricks-uploads` bucket
- **SSL**: Automatic certificate generation for the files subdomain
### Access Information
After setup, you'll see:
```bash
🗄️ MinIO Storage Setup Complete:
• S3 API: https://files.yourdomain.com
• Access Key: formbricks-a1b2c3d4
• Bucket: formbricks-uploads (✅ automatically created)
```
### DNS Requirements
<Warning>
**Critical for MinIO**: The subdomain configuration is mandatory for MinIO to function. Without proper
subdomain DNS setup, MinIO will fail to work entirely.
</Warning>
For the bundled MinIO setup, ensure:
1. **Main domain**: `yourdomain.com` points to your server IP
2. **Files subdomain**: `files.yourdomain.com` points to your server IP (this is required for MinIO to work)
3. **Firewall**: Ports 80 and 443 are open in your server's firewall
4. **DNS propagation**: Allow time for DNS changes to propagate globally
## Docker Compose Configuration
For manual setup, update your `docker-compose.yml`:
```yaml
services:
formbricks:
image: ghcr.io/formbricks/formbricks:latest
environment:
# ... other environment variables ...
# S3 Storage Configuration
S3_ACCESS_KEY: your_access_key
S3_SECRET_KEY: your_secret_key
S3_REGION: us-east-1
S3_BUCKET_NAME: your-bucket-name
S3_ENDPOINT_URL: https://your-endpoint.com # Optional
S3_FORCE_PATH_STYLE: 1 # For third-party services
volumes:
- uploads:/home/nextjs/apps/web/uploads/ # Still needed for temporary files
```
## Security Considerations
### S3 Bucket Permissions
Configure your S3 bucket with a least-privileged policy:
1. **Scoped Public Read Access**: Only allow public read access to specific prefixes where needed
2. **Restricted Write Access**: Only your Formbricks instance should be able to upload files
3. **CORS Configuration**: Allow requests from your Formbricks domain
Example least-privileged S3 bucket policy:
```json
{
"Statement": [
{
"Action": "s3:GetObject",
"Effect": "Allow",
"Principal": "*",
"Resource": "arn:aws:s3:::your-bucket-name/uploads/public/*",
"Sid": "PublicReadForPublicUploads"
},
{
"Action": ["s3:PutObject", "s3:PutObjectAcl"],
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:user/formbricks-service"
},
"Resource": "arn:aws:s3:::your-bucket-name/*",
"Sid": "AllowFormbricksWrite"
}
],
"Version": "2012-10-17"
}
```
### MinIO Security
When using bundled MinIO:
- Credentials are auto-generated and secure
- Access is restricted through Traefik proxy
- CORS is automatically configured
- Rate limiting is applied to prevent abuse
- A bucket policy with the least privileges is applied to the bucket
## Troubleshooting
### Common Issues
**Files not uploading:**
1. Check that S3 credentials are correct
2. Verify bucket exists and is accessible
3. Ensure bucket permissions allow uploads from your server
4. Check network connectivity to S3 endpoint
**Images not displaying in surveys:**
1. Verify bucket has public read access
2. Check CORS configuration allows requests from your domain
3. Ensure S3_ENDPOINT_URL is correctly set for third-party services
**MinIO not starting:**
1. **Verify subdomain DNS**: Ensure `files.yourdomain.com` points to your server IP (this is the most common issue)
2. **Check DNS propagation**: Use tools like `nslookup` or `dig` to verify DNS resolution
3. **Verify ports**: Ensure ports 80 and 443 are open in your firewall
4. **SSL certificate**: Check that SSL certificate generation completed successfully
5. **Container logs**: Check Docker container logs: `docker compose logs minio`
### Testing Your Configuration
To test if file uploads are working:
1. **Admin Panel**: Try uploading a project logo in the project settings
2. **Survey Editor**: Attempt to add a background image to a survey
3. **Question Types**: Create a 'File Upload' or 'Picture Selection' question
4. **Check Logs**: Monitor container logs for any storage-related errors
```bash
# Check Formbricks logs
docker compose logs formbricks
# Check MinIO logs (if using bundled MinIO)
docker compose logs minio
```
For additional help, join the conversation on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions).

View File

@@ -120,7 +120,9 @@ graph TD
## Redis Configuration
<Note>Redis is required for Formbricks to function. The application will not start without a Redis URL configured.</Note>
<Note>
Redis is required for Formbricks to function. The application will not start without a Redis URL configured.
</Note>
Configure Redis by adding the following **required** environment variable to your instances:

View File

@@ -11,7 +11,8 @@ The image is pre-built and requires minimal setup—just download it and start t
Make sure Docker and Docker Compose are installed on your system. These are usually included in tools like Docker Desktop and Rancher Desktop.
<Note>
`docker compose` without the hyphen is now the primary method of using docker-compose, according to the Docker documentation.
`docker compose` without the hyphen is now the primary method of using docker-compose, according to the
Docker documentation.
</Note>
## Start
@@ -29,7 +30,7 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
Get the docker-compose file from the Formbricks repository by running:
```bash
curl -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/main/docker/docker-compose.yml
curl -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/docker-compose.yml
```
1. **Generate NextAuth Secret**
@@ -64,21 +65,21 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
sed -i '' "s/ENCRYPTION_KEY:.*/ENCRYPTION_KEY: $(openssl rand -hex 32)/" docker-compose.yml
```
1. **Generate Cron Secret**
1. **Generate Cron Secret**
You require a Cron secret to secure API access for running cron jobs. Run one of the commands below based on your operating system:
You require a Cron secret to secure API access for running cron jobs. Run one of the commands below based on your operating system:
For Linux:
For Linux:
```bash
sed -i "/CRON_SECRET:$/s/CRON_SECRET:.*/CRON_SECRET: $(openssl rand -hex 32)/" docker-compose.yml
```
```bash
sed -i "/CRON_SECRET:$/s/CRON_SECRET:.*/CRON_SECRET: $(openssl rand -hex 32)/" docker-compose.yml
```
For macOS:
For macOS:
```bash
sed -i '' "s/CRON_SECRET:.*/CRON_SECRET: $(openssl rand -hex 32)/" docker-compose.yml
```
```bash
sed -i '' "s/CRON_SECRET:.*/CRON_SECRET: $(openssl rand -hex 32)/" docker-compose.yml
```
1. **Start the Docker Setup**

View File

@@ -9,32 +9,34 @@ icon: "rocket"
If youre looking to quickly set up a production instance of Formbricks on an Ubuntu server, this guide is for you. Using a convenient shell script, you can install everything—including Docker, Postgres DB, and an SSL certificate—in just a few steps. The script takes care of all the dependencies and configuration for your server, making the process smooth and simple.
<Note>
This setup uses **Traefik** as a **reverse proxy**, essential for directing incoming traffic to the correct container and enabling secure internet access to Formbricks. Traefik is chosen for its simplicity and automatic SSL management via Lets Encrypt.
This setup uses **Traefik** as a **reverse proxy**, essential for directing incoming traffic to the correct
container and enabling secure internet access to Formbricks. Traefik is chosen for its simplicity and
automatic SSL management via Lets Encrypt.
</Note>
For other operating systems or a more customized installation, please refer to the advanced installation guide with [Docker](/self-hosting/setup/docker).
### Requirements
* An Ubuntu Virtual Machine with SSH access.
- An Ubuntu Virtual Machine with SSH access.
* A custom domain with an **A record** pointing to your server.
- A custom domain with an **A record** pointing to your server.
* Ports **80** and **443** are open in your VM's Security Group, allowing Traefik to create an SSL certificate.
- Ports **80** and **443** are open in your VM's Security Group, allowing Traefik to create an SSL certificate.
### Deployment
Run this command in your terminal:
```bash
curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/main/docker/formbricks.sh -o formbricks.sh && chmod +x formbricks.sh && ./formbricks.sh install
curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/formbricks.sh -o formbricks.sh && chmod +x formbricks.sh && ./formbricks.sh install
```
### Script Prompts
During installation, the script will prompt you to provide some details:
* **Overwriting Docker GPG Keys**:
- **Overwriting Docker GPG Keys**:
If Docker GPG keys already exist, the script will ask whether you want to overwrite them.
```
@@ -50,7 +52,7 @@ During installation, the script will prompt you to provide some details:
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N)
```
* **Domain Name**:
- **Domain Name**:
Enter the domain name where youll host Formbricks. The domain will be used to generate an SSL certificate. Do not include the protocol (http/https).
```
@@ -74,7 +76,7 @@ File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
```
* **HTTPS Certificate Setup**:
- **HTTPS Certificate Setup**:
The script will ask if youd like to create an HTTPS certificate for your domain. Enter `Y` to proceed (highly recommended for secure access).
```
@@ -100,7 +102,7 @@ my.hosted.url.com
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
```
* **DNS Setup Prompt**: Ensure that your domain's DNS is correctly configured and ports 80 and 443 are open. Confirm this by entering `Y`. This step is crucial for proper SSL certificate issuance and secure server access.
- **DNS Setup Prompt**: Ensure that your domain's DNS is correctly configured and ports 80 and 443 are open. Confirm this by entering `Y`. This step is crucial for proper SSL certificate issuance and secure server access.
```
🚀 Executing default step of installing Formbricks
@@ -127,7 +129,7 @@ Y
🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]
```
* **Email Address for SSL Certificate**:
- **Email Address for SSL Certificate**:
Provide an email address to register the SSL certificate. Notifications regarding the certificate will be sent to this address.
```
@@ -157,7 +159,7 @@ Y
💡 Please enter your email address for the SSL certificate:
```
* **Enforce HTTPS with HSTS**:
- **Enforce HTTPS with HSTS**:
Enabling HTTP Strict Transport Security (HSTS) ensures all communication with your server is encrypted. Its a recommended best practice. Enter `Y` to enforce HTTPS.
```
@@ -189,7 +191,7 @@ docs@formbricks.com
🔗 Do you want to enforce HTTPS (HSTS)? [Y/n]
```
* **Email Service Setup Prompt**: The script will ask if you want to set up the email service. Enter `Y` to proceed.(default is `N`). You can skip this step if you don't want to set up the email service. You will still be able to use Formbricks without setting up the email service.
- **Email Service Setup Prompt**: The script will ask if you want to set up the email service. Enter `Y` to proceed.(default is `N`). You can skip this step if you don't want to set up the email service. You will still be able to use Formbricks without setting up the email service.
```
🚀 Executing default step of installing Formbricks
@@ -267,7 +269,7 @@ Y
🚙 Updating docker-compose.yml with your custom inputs...
🚗 NEXTAUTH_SECRET updated successfully!
🚗 ENCRYPTION_KEY updated successfully!
🚗 CRON_SECRET updated successfully!
🚗 CRON_SECRET updated successfully!
[+] Running 4/4
✔ Network formbricks_default Created 0.2s
@@ -332,13 +334,13 @@ If you encounter any issues, you can check the logs of the containers with:
If you encounter any issues, consider the following steps:
* **Inbound Rules**: Make sure you have added inbound rules for Port 80 and 443 in your VM's Security Group.
- **Inbound Rules**: Make sure you have added inbound rules for Port 80 and 443 in your VM's Security Group.
* **A Record**: Verify that you have set up an A record for your domain, pointing to your VM's IP address.
- **A Record**: Verify that you have set up an A record for your domain, pointing to your VM's IP address.
* **Check Docker Instances**: Run `docker ps` to check the status of the Docker instances.
- **Check Docker Instances**: Run `docker ps` to check the status of the Docker instances.
* **Check Formbricks Logs**: Run `cd formbricks && docker compose logs` to check the logs of the Formbricks stack.
- **Check Formbricks Logs**: Run `cd formbricks && docker compose logs` to check the logs of the Formbricks stack.
If you have any questions or require help, feel free to reach out to us on [**GitHub Discussions**](https://github.com/formbricks/formbricks/discussions). 😃[
](https://formbricks.com/docs/developer-docs/rest-api)

View File

@@ -4,14 +4,16 @@ description: "Branding the emails that are sent to your respondents."
icon: "envelope"
---
<Note>
**Self-Hosting Requirements**: Uploading custom organization logos for emails requires file upload storage
to be configured. If you're self-hosting Formbricks, make sure to [configure file
uploads](/self-hosting/configuration/file-uploads) before using this feature.
</Note>
Email branding is a white-label feature that allows you to customize the email that is sent to your users. You can upload a logo of your company and use it in the email.
<Note>
Email branding is part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license).
</Note>
<Info>
Only the Owner and Managers of the organization can modify the logo.
</Info>
<Note>Email branding is part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license).</Note>
<Info>Only the Owner and Managers of the organization can modify the logo.</Info>
## How to upload a logo

View File

@@ -6,12 +6,14 @@ description: "A step-by-step guide to integrate Airtable with Formbricks Cloud."
The Airtable integration allows you to automatically send responses to an Airtable of your choice.
<Note>
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/configuration/integrations) to configure integrations on your self-hosted instance.
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow
the guides [here](/self-hosting/configuration/integrations) to configure integrations on your self-hosted
instance.
</Note>
## Formbricks Cloud
1. Go to the Integrations tab in your [Formbricks Cloud dashboard](https://app.formbricks.com/) and click on the "Connect" button under Airtable integration.
1. Click on the `Configuration` tab in the left sidebar and then click on the `Integrations` tab and click on the `connect` button under the `Airtable` card.
![Formbricks Integration Tab](/images/xm-and-surveys/core-features/integrations/airtable/integrations-tab.webp)
@@ -28,7 +30,8 @@ The Airtable integration allows you to automatically send responses to an Airtab
![Formbricks is now connected with Google](/images/xm-and-surveys/core-features/integrations/airtable/airtable-connected.webp)
<Note>
Before the next step, make sure that you have a Formbricks Survey with at least one question and a Airtable base with atleast one table in the Airtable account you integrated.
Before the next step, make sure that you have a Formbricks Survey with at least one question and a Airtable
base with atleast one table in the Airtable account you integrated.
</Note>
1. Now click on the "Link New Table" button to link an Airtable with Formbricks and a modal will open up.
@@ -61,4 +64,4 @@ To remove the integration with Airtable,
![Delete Airtable Integration with Formbricks](/images/xm-and-surveys/core-features/integrations/airtable/delete-integration.webp)
Still struggling or something not working as expected? [Join our Github Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you!
Still struggling or something not working as expected? [Join our Github Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you!

View File

@@ -1,16 +1,17 @@
---
title: "Google Sheets"
description:
"The Google Sheets integration allows you to automatically send responses to a Google Sheet of your choice."
description: "The Google Sheets integration allows you to automatically send responses to a Google Sheet of your choice."
---
<Note>
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/configuration/integrations) to configure integrations on your self-hosted instance.
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow
the guides [here](/self-hosting/configuration/integrations) to configure integrations on your self-hosted
instance.
</Note>
## Connect Google Sheets
1. Go to the Integrations tab in your [Formbricks Cloud dashboard](https://app.formbricks.com/) and click on the "Connect" button under Google Sheets integration.
1. Click on the `Configuration` tab in the left sidebar and then click on the `Integrations` tab and click on the `connect` button under the `Google Sheets` card.
![Formbricks Integrations Tab](/images/xm-and-surveys/core-features/integrations/google-sheets/integrations-tab.webp)
@@ -25,7 +26,8 @@ description:
![Formbricks is now connected with Google](/images/xm-and-surveys/core-features/integrations/google-sheets/google-connected.webp)
<Note>
Before the next step, make sure that you have a Formbricks Survey with at least one question and a Google Sheet in the Google account you integrated.
Before the next step, make sure that you have a Formbricks Survey with at least one question and a Google
Sheet in the Google account you integrated.
</Note>
1. Now click on the "Link New Sheet" button to link a Google Sheet with Formbricks and a modal will open up.
@@ -58,11 +60,11 @@ To remove the integration with Google Account,
## What info do you need?
* Your **Email ID** for authentication (We use this to identify you)
- Your **Email ID** for authentication (We use this to identify you)
* Your **Google Sheets Names and IDs** (We fetch this to list and show you the options of choosing a sheet to integrate with)
- Your **Google Sheets Names and IDs** (We fetch this to list and show you the options of choosing a sheet to integrate with)
* Write access to **selected Google Sheet** (The google sheet you choose to integrate it with, we write survey responses to it)
- Write access to **selected Google Sheet** (The google sheet you choose to integrate it with, we write survey responses to it)
For the above, we ask for:
@@ -72,4 +74,4 @@ For the above, we ask for:
<Note>We store as little personal information as possible.</Note>
Still struggling or something not working as expected? [Join our Github Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you!
Still struggling or something not working as expected? [Join our Github Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you!

View File

@@ -1,16 +1,17 @@
---
title: "Notion"
description:
"The notion integration allows you to automatically send responses to a Notion database of your choice."
description: "The notion integration allows you to automatically send responses to a Notion database of your choice."
---
<Note>
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow the guides [here](/self-hosting/configuration/integrations) to configure integrations on your self-hosted instance.
If you are on a self-hosted instance, you will need to configure this integration separately. Please follow
the guides [here](/self-hosting/configuration/integrations) to configure integrations on your self-hosted
instance.
</Note>
## Formbricks Cloud
1. Go to the Integrations tab in your [Formbricks Cloud dashboard](https://app.formbricks.com/) and click on the "Connect" button under Notion integration.
1. Click on the `Configuration` tab in the left sidebar and then click on the `Integrations` tab and click on the `connect` button under the `Notion` card.
![Formbricks Integrations Tab](/images/xm-and-surveys/core-features/integrations/notion/integrations-tab.webp)
@@ -25,8 +26,8 @@ description:
![Formbricks is now connected with Notion](/images/xm-and-surveys/core-features/integrations/notion/notion-connected.webp)
<Note>
Before the next step, make sure that you have a Formbricks Survey with at
least one question and a Notion database in the Notion account you integrated.
Before the next step, make sure that you have a Formbricks Survey with at least one question and a Notion
database in the Notion account you integrated.
</Note>
1. Now click on the "Link New Database" button to link a Notion database with Formbricks and a modal will open up.
@@ -57,17 +58,17 @@ Enabling the Notion Integration in a self-hosted environment requires a setup us
5. Now provide it the details such as requested. Under **Redirect URIs** field:
* If you are running formbricks locally, you can enter `http://localhost:3000/api/v1/integrations/notion/callback`.
- If you are running formbricks locally, you can enter `http://localhost:3000/api/v1/integrations/notion/callback`.
* Or, you can enter `https://<your-public-facing-url>/api/v1/integrations/notion/callback`
- Or, you can enter `https://<your-public-facing-url>/api/v1/integrations/notion/callback`
6. Once you've filled all the necessary details, click on **Submit**.
7. A screen will appear which will have **Client ID** and **Client secret**. Copy them and set them as the environment variables in your Formbricks instance as:
* `NOTION_OAUTH_CLIENT_ID` - OAuth Client ID
- `NOTION_OAUTH_CLIENT_ID` - OAuth Client ID
* `NOTION_OAUTH_CLIENT_SECRET` - OAuth Client Secret
- `NOTION_OAUTH_CLIENT_SECRET` - OAuth Client Secret
Voila! You have successfully enabled the Notion integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in the [Formbricks Cloud](#formbricks-cloud) section to link a Notion database with Formbricks.
@@ -85,4 +86,4 @@ To remove the integration with Slack Workspace,
![Delete Notion Integration with Formbricks](/images/xm-and-surveys/core-features/integrations/notion/delete-connection.webp)
Still struggling or something not working as expected? [Join our Github Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you!
Still struggling or something not working as expected? [Join our Github Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you!

View File

@@ -10,7 +10,7 @@ description:
## Formbricks Cloud
1. Go to the Integrations tab in your [Formbricks Cloud dashboard](https://app.formbricks.com/) and click on the "Connect" button under Slack integration.
1. Click on the `Configuration` tab in the left sidebar and then click on the `Integrations` tab and click on the `connect` button under the `Slack` card.
![Formbricks Integrations Tab](/images/xm-and-surveys/core-features/integrations/slack/integrations-tab.webp)

View File

@@ -22,9 +22,9 @@ You can create webhooks either through the **Formbricks App UI** or programmatic
## **Creating Webhooks via UI**
- **Log in to Formbricks**
Navigate to the **Integrations** Tab after logging in.
and click on the `Configuration` tab in the left sidebar and then click on the `Integrations` tab.
![Step one](https://res.cloudinary.com/dwdb9tvii/image/upload/v1738093544/mugcz9gn3wxg2cucq6wj.webp)
![Step one](/images/xm-and-surveys/core-features/integrations/webhooks/integrations-tab.webp)
- Click on **Manage Webhooks** & then **Add Webhook** button:
@@ -58,133 +58,130 @@ Example of Response Created webhook payload:
```json
[
{
"webhookId": "webhookId",
"event": "responseCreated",
"data": {
"id": "responseId",
"createdAt": "2025-07-24T07:47:29.507Z",
"updatedAt": "2025-07-24T07:47:29.507Z",
"surveyId": "surveyId",
"displayId": "displayId",
"contact": null,
"contactAttributes": null,
"finished": false,
"endingId": null,
"data": {
"q1": "clicked"
},
"variables": {},
"ttc": {
"q1": 2154.700000047684
},
"tags": [],
"meta": {
"url": "https://app.formbricks.com/s/surveyId",
"userAgent": {
"browser": "Chrome",
"os": "macOS",
"device": "desktop"
},
"country": "DE"
},
"singleUseId": null,
"language": "en"
{
"data": {
"contact": null,
"contactAttributes": null,
"createdAt": "2025-07-24T07:47:29.507Z",
"data": {
"q1": "clicked"
},
"displayId": "displayId",
"endingId": null,
"finished": false,
"id": "responseId",
"language": "en",
"meta": {
"country": "DE",
"url": "https://app.formbricks.com/s/surveyId",
"userAgent": {
"browser": "Chrome",
"device": "desktop",
"os": "macOS"
}
}
},
"singleUseId": null,
"surveyId": "surveyId",
"tags": [],
"ttc": {
"q1": 2154.700000047684
},
"updatedAt": "2025-07-24T07:47:29.507Z",
"variables": {}
},
"event": "responseCreated",
"webhookId": "webhookId"
}
]
```
### Response Updated
Example of Response Updated webhook payload:
```json
[
{
"webhookId": "webhookId",
"event": "responseUpdated",
"data": {
"id": "responseId",
"createdAt": "2025-07-24T07:47:29.507Z",
"updatedAt": "2025-07-24T07:47:33.696Z",
"surveyId": "surveyId",
"displayId": "displayId",
"contact": null,
"contactAttributes": null,
"finished": false,
"endingId": null,
"data": {
"q1": "clicked",
"q2": "Just browsing"
},
"variables": {},
"ttc": {
"q1": 2154.700000047684,
"q2": 3855.799999952316
},
"tags": [],
"meta": {
"url": "https://app.formbricks.com/s/surveyId",
"userAgent": {
"browser": "Chrome",
"os": "macOS",
"device": "desktop"
},
"country": "DE"
},
"singleUseId": null,
"language": "en"
{
"data": {
"contact": null,
"contactAttributes": null,
"createdAt": "2025-07-24T07:47:29.507Z",
"data": {
"q1": "clicked",
"q2": "Just browsing"
},
"displayId": "displayId",
"endingId": null,
"finished": false,
"id": "responseId",
"language": "en",
"meta": {
"country": "DE",
"url": "https://app.formbricks.com/s/surveyId",
"userAgent": {
"browser": "Chrome",
"device": "desktop",
"os": "macOS"
}
}
},
"singleUseId": null,
"surveyId": "surveyId",
"tags": [],
"ttc": {
"q1": 2154.700000047684,
"q2": 3855.799999952316
},
"updatedAt": "2025-07-24T07:47:33.696Z",
"variables": {}
},
"event": "responseUpdated",
"webhookId": "webhookId"
}
]
```
### Response Finished
Example of Response Finished webhook payload:
```json
[
{
"webhookId": "webhookId",
"event": "responseFinished",
"data": {
"id": "responseId",
"createdAt": "2025-07-24T07:47:29.507Z",
"updatedAt": "2025-07-24T07:47:56.116Z",
"surveyId": "surveyId",
"displayId": "displayId",
"contact": null,
"contactAttributes": null,
"finished": true,
"endingId": "endingId",
"data": {
"q1": "clicked",
"q2": "accepted"
},
"variables": {},
"ttc": {
"_total": 4947.899999035763,
"q1": 2154.700000047684,
"q2": 2793.199999988079
},
"tags": [],
"meta": {
"url": "https://app.formbricks.com/s/surveyId",
"userAgent": {
"browser": "Chrome",
"os": "macOS",
"device": "desktop"
},
"country": "DE"
},
"singleUseId": null,
"language": "en"
{
"data": {
"contact": null,
"contactAttributes": null,
"createdAt": "2025-07-24T07:47:29.507Z",
"data": {
"q1": "clicked",
"q2": "accepted"
},
"displayId": "displayId",
"endingId": "endingId",
"finished": true,
"id": "responseId",
"language": "en",
"meta": {
"country": "DE",
"url": "https://app.formbricks.com/s/surveyId",
"userAgent": {
"browser": "Chrome",
"device": "desktop",
"os": "macOS"
}
}
},
"singleUseId": null,
"surveyId": "surveyId",
"tags": [],
"ttc": {
"_total": 4947.899999035763,
"q1": 2154.700000047684,
"q2": 2793.199999988079
},
"updatedAt": "2025-07-24T07:47:56.116Z",
"variables": {}
},
"event": "responseFinished",
"webhookId": "webhookId"
}
]
```

View File

@@ -1,7 +1,6 @@
---
title: "Wordpress"
description:
"Target specific visitors with a survey on your WordPress page using Formbricks for free. Show survey on specific page or on button click."
description: "Target specific visitors with a survey on your WordPress page using Formbricks for free. Show survey on specific page or on button click."
---
To run a targeted survey on your WordPress website, Formbricks is the way to go!&#x20;
@@ -36,7 +35,7 @@ When you see this screen, youre there:
## Step 3: Find and copy the environmentId
Go to Settings > Setup Checklist where youll find your environmentId:
Go to `Configuration` > `Website & App Connection` where youll find your environmentId:
![Run targeted surveys for free on WordPress pages](/images/xm-and-surveys/core-features/integrations/wordpress/3-wordpress-setup-survey-on-website-targeted-free-open-source.webp)
@@ -80,4 +79,4 @@ You did it! Reload the WordPress page and your survey should appear!
## Doesn't work?
If you have any questions or need help, feel free to reach out to us on [Github Discussions](https://github.com/formbricks/formbricks/discussions)
If you have any questions or need help, feel free to reach out to us on [Github Discussions](https://github.com/formbricks/formbricks/discussions)

View File

@@ -1,10 +1,15 @@
---
title: "Styling Theme"
description:
"Keep the survey styling consistent over all surveys with a Styling Theme. Customize the colors, fonts, and other styling options to match your brand's aesthetic."
description: "Keep the survey styling consistent over all surveys with a Styling Theme. Customize the colors, fonts, and other styling options to match your brand's aesthetic."
icon: "palette"
---
<Note>
**Self-Hosting Requirements**: Uploading custom background images and brand logos requires file upload
storage to be configured. If you're self-hosting Formbricks, make sure to [configure file
uploads](/self-hosting/configuration/file-uploads) before using these features.
</Note>
Keep the survey styling consistent over all surveys with a Styling Theme. Customize the colors, fonts, and other styling options to match your brand's aesthetic.
## Configuration
@@ -20,7 +25,6 @@ In the left side bar, you find the `Configuration` page. On this page you find t
![Form styling options UI](/images/xm-and-surveys/core-features/styling-theme/form-settings.webp)
- **Brand Color**: Sets the primary color tone of the survey.
- **Text Color**: This is a single color scheme that will be used across to display all the text on your survey. Ensures all text is readable against the background.
- **Input Color:** Alters the border color of input fields.
@@ -63,17 +67,14 @@ Customize your survey with your brand's logo.
![Choose a link survey template](/images/xm-and-surveys/core-features/styling-theme/step-five.webp)
3. Add a background color: If youve uploaded a transparent image and want to add background to it, enable this toggle and select the color of your choice.
![Choose a link survey template](/images/xm-and-surveys/core-features/styling-theme/step-six.webp)
4. Remember to save your changes!
![Choose a link survey template](/images/xm-and-surveys/core-features/styling-theme/step-seven.webp)
<Note>The logo settings apply across all Link Surveys pages.</Note>
## Overwrite Styling Theme

View File

@@ -9,10 +9,10 @@ Add new members to your Formbricks organization to collaborate on surveys and ma
## Prerequisites
To invite members, you need:
- **Owner** or **Manager** role in the organization
- Valid email addresses for the people you want to invite
## Individual invitations
Use this method when inviting a few people or when you need to carefully control each invitation.
@@ -22,18 +22,21 @@ Use this method when inviting a few people or when you need to carefully control
<Steps>
<Step title="Navigate to Organization Settings > Access Control">
Go to the organization settings page and click on the "Access Control" tab.
![Access Control Tab](/images/xm-and-surveys/core-features/access-roles/access-control.webp)
</Step>
<Step title="Start the invitation process">
Click on the `Add member` button:
![Add member Button Position](/images/xm-and-surveys/core-features/access-roles/add-member.webp)
</Step>
<Step title="Fill in member details">
In the modal, add the Name, Email and Role of the organization member you want to invite:
![Individual Invite Modal Tab](/images/xm-and-surveys/core-features/access-roles/individual-invite.webp)
</Step>
<Step title="Send the invitation">
@@ -62,12 +65,14 @@ Use bulk invitations when you need to invite many people at once, such as when o
Click on the `Add member` button:
![Add member Button Position](/images/xm-and-surveys/core-features/access-roles/add-member.webp)
</Step>
<Step title="Switch to bulk invite">
In the modal, switch to `Bulk Invite`. You can download an example .CSV file to fill in the Name, Email and Role of the organization members you want to invite:
![Individual Invite Modal Tab](/images/xm-and-surveys/core-features/access-roles/bulk-invite.webp)
</Step>
<Step title="Prepare your CSV file">
@@ -99,6 +104,7 @@ Use bulk invitations when you need to invite many people at once, such as when o
### Invitation status
Monitor the status of your invitations:
- **Pending**: Invitation sent but not yet accepted
- **Accepted**: User has joined the organization
- **Expired**: Invitation has expired and needs to be resent
- **Expired**: Invitation has expired and needs to be resent

View File

@@ -4,6 +4,12 @@ description: "Enhance your questions by adding images or videos. This makes inst
icon: "image"
---
<Note>
**Self-Hosting Requirements**: Adding images to questions requires file upload storage to be configured. If
you're self-hosting Formbricks, make sure to [configure file
uploads](/self-hosting/configuration/file-uploads) before using this feature.
</Note>
## How to Add Images
Click the icon on the right side of the question to add an image or video:
@@ -25,6 +31,6 @@ Toggle to add a video via link:
We support YouTube, Vimeo, and Loom URLs.
<Note>
**YouTube Privacy Mode**: This option reduces tracking by converting YouTube
URLs to no-cookie URLs. It only works with YouTube.
**YouTube Privacy Mode**: This option reduces tracking by converting YouTube URLs to no-cookie URLs. It only
works with YouTube.
</Note>

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