mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-18 06:52:01 -05:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75f05f85e9 | |||
| 74405cc05f | |||
| 785359955a | |||
| f6157d5109 | |||
| 070dd9f268 | |||
| 7a40d647d8 | |||
| 2186a1c60d | |||
| 2054de4a9d | |||
| e068955fbf | |||
| 4f5180ea8f |
@@ -1,152 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# EKS & ALB Optimization Guide for Error Reduction
|
||||
|
||||
## Infrastructure Overview
|
||||
|
||||
This project uses AWS EKS with Application Load Balancer (ALB) for the Formbricks application. The infrastructure has been optimized to minimize ELB 502/504 errors through careful configuration of connection handling, health checks, and pod lifecycle management.
|
||||
|
||||
## Key Infrastructure Files
|
||||
|
||||
### Terraform Configuration
|
||||
- **Main Infrastructure**: [infra/terraform/main.tf](mdc:infra/terraform/main.tf) - EKS cluster, VPC, Karpenter, and core AWS resources
|
||||
- **Monitoring**: [infra/terraform/cloudwatch.tf](mdc:infra/terraform/cloudwatch.tf) - CloudWatch alarms for 502/504 error tracking and alerting
|
||||
- **Database**: [infra/terraform/rds.tf](mdc:infra/terraform/rds.tf) - Aurora PostgreSQL configuration
|
||||
|
||||
### Helm Configuration
|
||||
- **Production**: [infra/formbricks-cloud-helm/values.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values.yaml.gotmpl) - Optimized ALB and pod configurations
|
||||
- **Staging**: [infra/formbricks-cloud-helm/values-staging.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values-staging.yaml.gotmpl) - Staging environment with spot instances
|
||||
- **Deployment**: [infra/formbricks-cloud-helm/helmfile.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/helmfile.yaml.gotmpl) - Multi-environment Helm releases
|
||||
|
||||
## ALB Optimization Patterns
|
||||
|
||||
### Connection Handling Optimizations
|
||||
```yaml
|
||||
# Key ALB annotations for reducing 502/504 errors
|
||||
alb.ingress.kubernetes.io/load-balancer-attributes: |
|
||||
idle_timeout.timeout_seconds=120,
|
||||
connection_logs.s3.enabled=false,
|
||||
access_logs.s3.enabled=false
|
||||
|
||||
alb.ingress.kubernetes.io/target-group-attributes: |
|
||||
deregistration_delay.timeout_seconds=30,
|
||||
stickiness.enabled=false,
|
||||
load_balancing.algorithm.type=least_outstanding_requests,
|
||||
target_group_health.dns_failover.minimum_healthy_targets.count=1
|
||||
```
|
||||
|
||||
### Health Check Configuration
|
||||
- **Interval**: 15 seconds for faster detection of unhealthy targets
|
||||
- **Timeout**: 5 seconds to prevent false positives
|
||||
- **Thresholds**: 2 healthy, 3 unhealthy for balanced responsiveness
|
||||
- **Path**: `/health` endpoint optimized for < 100ms response time
|
||||
|
||||
## Pod Lifecycle Management
|
||||
|
||||
### Graceful Shutdown Pattern
|
||||
```yaml
|
||||
# PreStop hook to allow connection draining
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["/bin/sh", "-c", "sleep 15"]
|
||||
|
||||
# Termination grace period for complete cleanup
|
||||
terminationGracePeriodSeconds: 45
|
||||
```
|
||||
|
||||
### Health Probe Strategy
|
||||
- **Startup Probe**: 5s initial delay, 5s interval, max 60s startup time
|
||||
- **Readiness Probe**: 10s delay, 10s interval for traffic readiness
|
||||
- **Liveness Probe**: 30s delay, 30s interval for container health
|
||||
|
||||
### Rolling Update Configuration
|
||||
```yaml
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 25% # Maintain capacity during updates
|
||||
maxSurge: 50% # Allow faster rollouts
|
||||
```
|
||||
|
||||
## Karpenter Node Management
|
||||
|
||||
### Node Lifecycle Optimization
|
||||
- **Startup Taints**: Prevent traffic during node initialization
|
||||
- **Graceful Shutdown**: 30s grace period for pod eviction
|
||||
- **Consolidation Delay**: 60s to reduce unnecessary churn
|
||||
- **Eviction Policies**: Configured for smooth pod migrations
|
||||
|
||||
### Instance Selection
|
||||
- **Families**: c8g, c7g, m8g, m7g, r8g, r7g (ARM64 Graviton)
|
||||
- **Sizes**: 2, 4, 8 vCPUs for cost optimization
|
||||
- **Bottlerocket AMI**: Enhanced security and performance
|
||||
|
||||
## Monitoring & Alerting
|
||||
|
||||
### Critical ALB Metrics
|
||||
1. **ELB 502 Errors**: Threshold 20 over 5 minutes
|
||||
2. **ELB 504 Errors**: Threshold 15 over 5 minutes
|
||||
3. **Target Connection Errors**: Threshold 50 over 5 minutes
|
||||
4. **4XX Errors**: Threshold 100 over 10 minutes (client issues)
|
||||
|
||||
### Expected Improvements
|
||||
- **60-80% reduction** in ELB 502 errors
|
||||
- **Faster recovery** during pod restarts
|
||||
- **Better connection reuse** efficiency
|
||||
- **Improved autoscaling** responsiveness
|
||||
|
||||
## Deployment Patterns
|
||||
|
||||
### Infrastructure Updates
|
||||
1. **Terraform First**: Apply infrastructure changes via [infra/deploy-improvements.sh](mdc:infra/deploy-improvements.sh)
|
||||
2. **Helm Second**: Deploy application configurations
|
||||
3. **Verification**: Check pod status, endpoints, and ALB health
|
||||
4. **Monitoring**: Watch CloudWatch metrics for 24-48 hours
|
||||
|
||||
### Environment-Specific Configurations
|
||||
- **Production**: On-demand instances, stricter resource limits
|
||||
- **Staging**: Spot instances, rate limiting disabled, relaxed resources
|
||||
|
||||
## Troubleshooting Patterns
|
||||
|
||||
### 502 Error Investigation
|
||||
1. Check pod readiness and health probe status
|
||||
2. Verify ALB target group health
|
||||
3. Review deregistration timing during deployments
|
||||
4. Monitor connection pool utilization
|
||||
|
||||
### 504 Error Analysis
|
||||
1. Check application response times
|
||||
2. Verify timeout configurations (ALB: 120s, App: aligned)
|
||||
3. Review database query performance
|
||||
4. Monitor resource utilization during traffic spikes
|
||||
|
||||
### Connection Error Patterns
|
||||
1. Verify Karpenter node lifecycle timing
|
||||
2. Check pod termination grace periods
|
||||
3. Review ALB connection draining settings
|
||||
4. Monitor cluster autoscaling events
|
||||
|
||||
## Best Practices
|
||||
|
||||
### When Making Changes
|
||||
- **Test in staging first** with same configurations
|
||||
- **Monitor metrics** for 24-48 hours after changes
|
||||
- **Use gradual rollouts** with proper health checks
|
||||
- **Maintain ALB timeout alignment** across all layers
|
||||
|
||||
### Performance Optimization
|
||||
- **Health endpoint** should respond < 100ms consistently
|
||||
- **Connection pooling** aligned with ALB idle timeouts
|
||||
- **Resource requests/limits** tuned for consistent performance
|
||||
- **Graceful shutdown** implemented in application code
|
||||
|
||||
### Monitoring Strategy
|
||||
- **Real-time alerts** for error rate spikes
|
||||
- **Trend analysis** for connection patterns
|
||||
- **Capacity planning** based on LCU usage
|
||||
- **4XX pattern analysis** for client behavior insights
|
||||
@@ -0,0 +1,165 @@
|
||||
name: PR Size Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-pr-size:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check PR size
|
||||
id: check-size
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Fetch the base branch
|
||||
git fetch origin "${{ github.base_ref }}"
|
||||
|
||||
# Get diff stats
|
||||
diff_output=$(git diff --numstat "origin/${{ github.base_ref }}"...HEAD)
|
||||
|
||||
# Count lines, excluding:
|
||||
# - Test files (*.test.ts, *.spec.tsx, etc.)
|
||||
# - Locale files (locales/*.json, i18n/*.json)
|
||||
# - Lock files (pnpm-lock.yaml, package-lock.json, yarn.lock)
|
||||
# - Generated files (dist/, coverage/, build/, .next/)
|
||||
# - Storybook stories (*.stories.tsx)
|
||||
|
||||
total_additions=0
|
||||
total_deletions=0
|
||||
counted_files=0
|
||||
excluded_files=0
|
||||
|
||||
while IFS=$'\t' read -r additions deletions file; do
|
||||
# Skip if additions or deletions are "-" (binary files)
|
||||
if [ "$additions" = "-" ] || [ "$deletions" = "-" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if file should be excluded
|
||||
case "$file" in
|
||||
*.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx|*.test.js|*.test.jsx|*.spec.js|*.spec.jsx)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
*/locales/*.json|*/i18n/*.json)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
pnpm-lock.yaml|package-lock.json|yarn.lock)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
dist/*|coverage/*|build/*|node_modules/*|test-results/*|playwright-report/*|.next/*|*.tsbuildinfo)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
*.stories.ts|*.stories.tsx|*.stories.js|*.stories.jsx)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
total_additions=$((total_additions + additions))
|
||||
total_deletions=$((total_deletions + deletions))
|
||||
counted_files=$((counted_files + 1))
|
||||
done <<EOF
|
||||
${diff_output}
|
||||
EOF
|
||||
|
||||
total_changes=$((total_additions + total_deletions))
|
||||
|
||||
echo "counted_files=${counted_files}" >> "${GITHUB_OUTPUT}"
|
||||
echo "excluded_files=${excluded_files}" >> "${GITHUB_OUTPUT}"
|
||||
echo "total_additions=${total_additions}" >> "${GITHUB_OUTPUT}"
|
||||
echo "total_deletions=${total_deletions}" >> "${GITHUB_OUTPUT}"
|
||||
echo "total_changes=${total_changes}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Set flag if PR is too large (> 800 lines)
|
||||
if [ ${total_changes} -gt 800 ]; then
|
||||
echo "is_too_large=true" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "is_too_large=false" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
- name: Comment on PR if too large
|
||||
if: steps.check-size.outputs.is_too_large == 'true'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const totalChanges = ${{ steps.check-size.outputs.total_changes }};
|
||||
const countedFiles = ${{ steps.check-size.outputs.counted_files }};
|
||||
const excludedFiles = ${{ steps.check-size.outputs.excluded_files }};
|
||||
const additions = ${{ steps.check-size.outputs.total_additions }};
|
||||
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
||||
|
||||
const body = `## 🚨 PR Size Warning
|
||||
|
||||
This PR has approximately **${totalChanges} lines** of changes (${additions} additions, ${deletions} deletions across ${countedFiles} files).
|
||||
|
||||
Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.
|
||||
|
||||
### 💡 Suggestions:
|
||||
- **Split by feature or module** - Break down into logical, independent pieces
|
||||
- **Create a sequence of PRs** - Each building on the previous one
|
||||
- **Branch off PR branches** - Don't wait for reviews to continue dependent work
|
||||
|
||||
### 📊 What was counted:
|
||||
- ✅ Source files, stylesheets, configuration files
|
||||
- ❌ Excluded ${excludedFiles} files (tests, locales, locks, generated files)
|
||||
|
||||
### 📚 Guidelines:
|
||||
- **Ideal:** 300-500 lines per PR
|
||||
- **Warning:** 500-800 lines
|
||||
- **Critical:** 800+ lines ⚠️
|
||||
|
||||
If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split.`;
|
||||
|
||||
// Check if we already commented
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('🚨 PR Size Warning')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: body
|
||||
});
|
||||
} else {
|
||||
// Create new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
name: "Terraform"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# TODO: enable it back when migration is completed.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
|
||||
- name: Terraform Format
|
||||
id: fmt
|
||||
run: terraform fmt -check -recursive
|
||||
continue-on-error: true
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Terraform Init
|
||||
id: init
|
||||
run: terraform init
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Terraform Validate
|
||||
id: validate
|
||||
run: terraform validate
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Terraform Plan
|
||||
id: plan
|
||||
run: terraform plan -out .planfile
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Post PR comment
|
||||
uses: borchero/terraform-plan-comment@434458316f8f24dd073cd2561c436cce41dc8f34 # v2.4.1
|
||||
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
planfile: .planfile
|
||||
working-directory: "infra/terraform"
|
||||
|
||||
- name: Terraform Apply
|
||||
id: apply
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
run: terraform apply .planfile
|
||||
working-directory: "infra/terraform"
|
||||
-13
@@ -56,19 +56,6 @@ packages/database/migrations
|
||||
branch.json
|
||||
.vercel
|
||||
|
||||
# Terraform
|
||||
infra/terraform/.terraform/
|
||||
**/.terraform.lock.hcl
|
||||
**/terraform.tfstate
|
||||
**/terraform.tfstate.*
|
||||
**/crash.log
|
||||
**/override.tf
|
||||
**/override.tf.json
|
||||
**/*.tfvars
|
||||
**/*.tfvars.json
|
||||
**/.terraformrc
|
||||
**/terraform.rc
|
||||
|
||||
# IntelliJ IDEA
|
||||
/.idea/
|
||||
/*.iml
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
@@ -36,6 +21,21 @@ import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
@@ -60,7 +60,7 @@ export const MainNavigation = ({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslate();
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
@@ -168,12 +168,6 @@ export const createEnvironment = async (
|
||||
description: "Your contact's last name",
|
||||
type: "default",
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
name: "Language",
|
||||
description: "The language preference of a contact",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1315,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "Tage, bevor diese Umfrage erneut angezeigt wird.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Entscheide, wie oft Leute diese Umfrage beantworten können.",
|
||||
"delete_choice": "Auswahl löschen",
|
||||
"description": "Beschreibung",
|
||||
"disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an",
|
||||
"display_number_of_responses_for_survey": "Anzahl der Antworten für Umfrage anzeigen",
|
||||
@@ -1344,6 +1343,7 @@
|
||||
"error_saving_changes": "Fehler beim Speichern der Änderungen",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Sogar nachdem sie eine Antwort eingereicht haben (z.B. Feedback-Box)",
|
||||
"everyone": "Jeder",
|
||||
"external_urls_paywall_tooltip": "Bitte aktualisieren, um die externe URL anzupassen. Phishing-Prävention.",
|
||||
"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",
|
||||
|
||||
@@ -1315,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "days before showing this survey again.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decide how often people can answer this survey.",
|
||||
"delete_choice": "Delete choice",
|
||||
"description": "Description",
|
||||
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
|
||||
"display_number_of_responses_for_survey": "Display number of responses for survey",
|
||||
@@ -1344,6 +1343,7 @@
|
||||
"error_saving_changes": "Error saving changes",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Even after they submitted a response (e.g. Feedback Box)",
|
||||
"everyone": "Everyone",
|
||||
"external_urls_paywall_tooltip": "Please upgrade to customize external URL. Phishing prevention.",
|
||||
"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",
|
||||
|
||||
@@ -1315,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "jours avant de montrer à nouveau cette enquête.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Décidez à quelle fréquence les gens peuvent répondre à cette enquête.",
|
||||
"delete_choice": "Supprimer l'option",
|
||||
"description": "Description",
|
||||
"disable_the_visibility_of_survey_progress": "Désactiver la visibilité de la progression du sondage.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afficher une estimation du temps de complétion pour l'enquête.",
|
||||
"display_number_of_responses_for_survey": "Afficher le nombre de réponses pour l'enquête",
|
||||
@@ -1344,6 +1343,7 @@
|
||||
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Même après avoir soumis une réponse (par exemple, la boîte de feedback)",
|
||||
"everyone": "Tout le monde",
|
||||
"external_urls_paywall_tooltip": "Veuillez passer à la version supérieure pour personnaliser l'URL externe. Prévention contre l'hameçonnage.",
|
||||
"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}\"",
|
||||
|
||||
@@ -1315,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "日後にこのフォームを再度表示します。",
|
||||
"decide_how_often_people_can_answer_this_survey": "このフォームに人々が何回回答できるかを決定します。",
|
||||
"delete_choice": "選択肢を削除",
|
||||
"description": "説明",
|
||||
"disable_the_visibility_of_survey_progress": "フォームの進捗状況の表示を無効にする。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "フォームの完了時間の目安を表示",
|
||||
"display_number_of_responses_for_survey": "フォームの回答数を表示",
|
||||
@@ -1344,6 +1343,7 @@
|
||||
"error_saving_changes": "変更の保存中にエラーが発生しました",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "回答を送信した後でも(例:フィードバックボックス)",
|
||||
"everyone": "全員",
|
||||
"external_urls_paywall_tooltip": "外部 URL をカスタマイズするにはアップグレードしてください 。 フィッシング防止 。",
|
||||
"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}\" クォータ で使用されています",
|
||||
|
||||
@@ -1315,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "dias antes de mostrar essa pesquisa de novo.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a essa pesquisa.",
|
||||
"delete_choice": "Deletar opção",
|
||||
"description": "Descrição",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa de tempo de conclusão da pesquisa",
|
||||
"display_number_of_responses_for_survey": "Mostrar número de respostas da pesquisa",
|
||||
@@ -1344,6 +1343,7 @@
|
||||
"error_saving_changes": "Erro ao salvar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de eles enviarem uma resposta (por exemplo, Caixa de Feedback)",
|
||||
"everyone": "Todo mundo",
|
||||
"external_urls_paywall_tooltip": "Por favor, faça upgrade para personalizar o URL externo. Prevenção de phishing.",
|
||||
"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}\"",
|
||||
|
||||
@@ -1315,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "dias antes de mostrar este inquérito novamente.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a este inquérito.",
|
||||
"delete_choice": "Eliminar escolha",
|
||||
"description": "Descrição",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa do tempo de conclusão do inquérito",
|
||||
"display_number_of_responses_for_survey": "Mostrar número de respostas do inquérito",
|
||||
@@ -1344,6 +1343,7 @@
|
||||
"error_saving_changes": "Erro ao guardar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de terem enviado uma resposta (por exemplo, Caixa de Feedback)",
|
||||
"everyone": "Todos",
|
||||
"external_urls_paywall_tooltip": "Por favor, atualize para personalizar o URL externo. Prevenção contra phishing.",
|
||||
"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}\"",
|
||||
|
||||
@@ -1315,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "zile înainte de a afișa din nou acest sondaj.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decide cât de des pot răspunde oamenii la acest sondaj",
|
||||
"delete_choice": "Șterge alegerea",
|
||||
"description": "Descriere",
|
||||
"disable_the_visibility_of_survey_progress": "Dezactivați vizibilitatea progresului sondajului",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afișează o estimare a timpului de finalizare pentru sondaj",
|
||||
"display_number_of_responses_for_survey": "Afișează numărul de răspunsuri pentru sondaj",
|
||||
@@ -1344,6 +1343,7 @@
|
||||
"error_saving_changes": "Eroare la salvarea modificărilor",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Chiar și după ce au furnizat un răspuns (de ex. Cutia de Feedback)",
|
||||
"everyone": "Toată lumea",
|
||||
"external_urls_paywall_tooltip": "Vă rugăm să faceți upgrade pentru a personaliza URL-ul extern. Prevenire phishing.",
|
||||
"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}\"",
|
||||
|
||||
@@ -1315,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "显示 此 调查 之前 的 天数。",
|
||||
"decide_how_often_people_can_answer_this_survey": "决定 人 可以 回答 这份 调查 的 频率 。",
|
||||
"delete_choice": "删除 选择",
|
||||
"description": "描述",
|
||||
"disable_the_visibility_of_survey_progress": "禁用问卷 进度 的可见性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "显示 调查 预计 完成 时间",
|
||||
"display_number_of_responses_for_survey": "显示 调查 响应 数量",
|
||||
@@ -1344,6 +1343,7 @@
|
||||
"error_saving_changes": "保存 更改 时 出错",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "即使 他们 提交 了 回复(例如 反馈框)",
|
||||
"everyone": "所有 人",
|
||||
"external_urls_paywall_tooltip": "请升级 以自定义 外部 URL 。 网络钓鱼 预防 。",
|
||||
"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}\" 配额 使用",
|
||||
|
||||
@@ -1315,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "天後再次顯示此問卷。",
|
||||
"decide_how_often_people_can_answer_this_survey": "決定人們可以回答此問卷的頻率。",
|
||||
"delete_choice": "刪除選項",
|
||||
"description": "描述",
|
||||
"disable_the_visibility_of_survey_progress": "停用問卷進度的可見性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "顯示問卷的估計完成時間",
|
||||
"display_number_of_responses_for_survey": "顯示問卷的回應數",
|
||||
@@ -1344,6 +1343,7 @@
|
||||
"error_saving_changes": "儲存變更時發生錯誤",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如,意見反應方塊)",
|
||||
"everyone": "所有人",
|
||||
"external_urls_paywall_tooltip": "請升級以自訂 external URL 。 Phishing 預防。",
|
||||
"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}\" 配額中",
|
||||
|
||||
@@ -319,51 +319,6 @@ describe("createContactsFromCSV", () => {
|
||||
createContactsFromCSV(csvData, environmentId, "skip", { email: "email", name: "name" })
|
||||
).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
test("handles language attribute key like other default attributes", async () => {
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
|
||||
{ key: "email", id: "id-email" },
|
||||
{ key: "userId", id: "id-userId" },
|
||||
{ key: "firstName", id: "id-firstName" },
|
||||
{ key: "lastName", id: "id-lastName" },
|
||||
{ key: "language", id: "id-language" },
|
||||
] as any);
|
||||
vi.mocked(prisma.contact.create).mockResolvedValue({
|
||||
id: "c1",
|
||||
environmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "john@example.com" },
|
||||
{ attributeKey: { key: "userId" }, value: "user123" },
|
||||
{ attributeKey: { key: "firstName" }, value: "John" },
|
||||
{ attributeKey: { key: "lastName" }, value: "Doe" },
|
||||
{ attributeKey: { key: "language" }, value: "en" },
|
||||
],
|
||||
} as any);
|
||||
const csvData = [
|
||||
{
|
||||
email: "john@example.com",
|
||||
userId: "user123",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
language: "en",
|
||||
},
|
||||
];
|
||||
const result = await createContactsFromCSV(csvData, environmentId, "skip", {
|
||||
email: "email",
|
||||
userId: "userId",
|
||||
firstName: "firstName",
|
||||
lastName: "lastName",
|
||||
language: "language",
|
||||
});
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result[0].id).toBe("c1");
|
||||
// language attribute key should already exist, no need to create it
|
||||
expect(prisma.contactAttributeKey.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildContactWhereClause", () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ interface LocalizedEditorProps {
|
||||
questionId: string;
|
||||
isCard?: boolean; // Flag to indicate if this is a welcome/ending card
|
||||
autoFocus?: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
const checkIfValueIsIncomplete = (
|
||||
@@ -58,6 +59,7 @@ export function LocalizedEditor({
|
||||
questionId,
|
||||
isCard,
|
||||
autoFocus,
|
||||
isExternalUrlsAllowed,
|
||||
}: Readonly<LocalizedEditorProps>) {
|
||||
const { t } = useTranslate();
|
||||
|
||||
@@ -90,6 +92,11 @@ export function LocalizedEditor({
|
||||
key={`${questionId}-${id}-${selectedLanguageCode}`}
|
||||
setFirstRender={setFirstRender}
|
||||
setText={(v: string) => {
|
||||
let sanitizedContent = v;
|
||||
if (!isExternalUrlsAllowed) {
|
||||
sanitizedContent = v.replaceAll(/<a[^>]*>(.*?)<\/a>/gi, "$1");
|
||||
}
|
||||
|
||||
// Check if the question still exists before updating
|
||||
const currentQuestion = localSurvey.questions[questionIdx];
|
||||
|
||||
@@ -113,8 +120,8 @@ export function LocalizedEditor({
|
||||
}
|
||||
|
||||
const translatedContent = {
|
||||
...(value ?? {}),
|
||||
[selectedLanguageCode]: v,
|
||||
...value,
|
||||
[selectedLanguageCode]: sanitizedContent,
|
||||
};
|
||||
updateQuestion({ [id]: translatedContent });
|
||||
return;
|
||||
@@ -122,8 +129,8 @@ export function LocalizedEditor({
|
||||
|
||||
if (currentQuestion && currentQuestion[id] !== undefined) {
|
||||
const translatedContent = {
|
||||
...(value ?? {}),
|
||||
[selectedLanguageCode]: v,
|
||||
...value,
|
||||
[selectedLanguageCode]: sanitizedContent,
|
||||
};
|
||||
updateQuestion(questionIdx, { [id]: translatedContent });
|
||||
}
|
||||
@@ -131,6 +138,7 @@ export function LocalizedEditor({
|
||||
localSurvey={localSurvey}
|
||||
questionId={questionId}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
{localSurvey.languages.length > 1 && (
|
||||
<div>
|
||||
|
||||
@@ -60,6 +60,7 @@ interface QuestionFormInputProps {
|
||||
autoFocus?: boolean;
|
||||
firstRender?: boolean;
|
||||
setFirstRender?: (value: boolean) => void;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const QuestionFormInput = ({
|
||||
@@ -85,6 +86,7 @@ export const QuestionFormInput = ({
|
||||
autoFocus,
|
||||
firstRender: externalFirstRender,
|
||||
setFirstRender: externalSetFirstRender,
|
||||
isExternalUrlsAllowed,
|
||||
}: QuestionFormInputProps) => {
|
||||
const { t } = useTranslate();
|
||||
const defaultLanguageCode =
|
||||
@@ -363,6 +365,7 @@ export const QuestionFormInput = ({
|
||||
questionId={questionId}
|
||||
isCard={isWelcomeCard || isEndingCard}
|
||||
autoFocus={autoFocus}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,24 +5,25 @@ import { actionClient, authenticatedActionClient } from "@/lib/utils/action-clie
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import {
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromProjectId,
|
||||
getOrganizationIdFromSurveyId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
getProjectIdFromSurveyId,
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromProjectId,
|
||||
getOrganizationIdFromSurveyId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
getProjectIdFromSurveyId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
|
||||
import { createActionClass } from "@/modules/survey/editor/lib/action-class";
|
||||
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
|
||||
import { updateSurvey } from "@/modules/survey/editor/lib/survey";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||
import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getProject } from "./lib/project";
|
||||
|
||||
/**
|
||||
@@ -82,6 +83,9 @@ export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).acti
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.id;
|
||||
const oldObject = await getSurvey(parsedInput.id);
|
||||
|
||||
// Check external URLs permission (with grandfathering)
|
||||
await checkExternalUrlsPermission(organizationId, parsedInput, oldObject);
|
||||
const result = await updateSurvey(parsedInput);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
@@ -21,6 +21,7 @@ interface AddressQuestionFormProps {
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const AddressQuestionForm = ({
|
||||
@@ -33,6 +34,7 @@ export const AddressQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: AddressQuestionFormProps): JSX.Element => {
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
|
||||
const { t } = useTranslate();
|
||||
@@ -109,6 +111,7 @@ export const AddressQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -128,6 +131,7 @@ export const AddressQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ interface CalQuestionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const CalQuestionForm = ({
|
||||
@@ -35,6 +36,7 @@ export const CalQuestionForm = ({
|
||||
isInvalid,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: CalQuestionFormProps): JSX.Element => {
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
const [isCalHostEnabled, setIsCalHostEnabled] = useState(!!question.calHost);
|
||||
@@ -64,6 +66,7 @@ export const CalQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
<div>
|
||||
{question.subheader !== undefined && (
|
||||
@@ -82,6 +85,7 @@ export const CalQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ConsentQuestionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const ConsentQuestionForm = ({
|
||||
@@ -28,6 +29,7 @@ export const ConsentQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: ConsentQuestionFormProps): JSX.Element => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
@@ -41,6 +43,7 @@ export const ConsentQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured,
|
||||
isExternalUrlsAllowed,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,6 +22,7 @@ interface ContactInfoQuestionFormProps {
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const ContactInfoQuestionForm = ({
|
||||
@@ -34,6 +35,7 @@ export const ContactInfoQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: ContactInfoQuestionFormProps): JSX.Element => {
|
||||
const { t } = useTranslate();
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
|
||||
@@ -99,6 +101,7 @@ export const ContactInfoQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -118,6 +121,7 @@ export const ContactInfoQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,7 @@ describe("CTAQuestionForm", () => {
|
||||
setSelectedLanguageCode={mockSetSelectedLanguageCode}
|
||||
locale={mockLocale}
|
||||
isStorageConfigured={true}
|
||||
isExternalUrlsAllowed={true}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { QuestionFormInput } from "@/modules/survey/components/question-form-inp
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface CTAQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -20,6 +21,7 @@ interface CTAQuestionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const CTAQuestionForm = ({
|
||||
@@ -33,6 +35,7 @@ export const CTAQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: CTAQuestionFormProps): JSX.Element => {
|
||||
const { t } = useTranslate();
|
||||
const options = [
|
||||
@@ -58,6 +61,7 @@ export const CTAQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
@@ -73,14 +77,28 @@ export const CTAQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<OptionsSwitch
|
||||
options={options}
|
||||
currentOption={question.buttonExternal ? "external" : "internal"}
|
||||
handleOptionChange={(e) => updateQuestion(questionIdx, { buttonExternal: e === "external" })}
|
||||
/>
|
||||
<TooltipRenderer
|
||||
shouldRender={!isExternalUrlsAllowed && !question.buttonExternal}
|
||||
tooltipContent={t("environments.surveys.edit.external_urls_paywall_tooltip")}>
|
||||
<OptionsSwitch
|
||||
options={options.map((opt) => ({
|
||||
...opt,
|
||||
disabled: opt.value === "external" && !isExternalUrlsAllowed && !question.buttonExternal,
|
||||
}))}
|
||||
currentOption={question.buttonExternal ? "external" : "internal"}
|
||||
handleOptionChange={(e) => {
|
||||
const canSwitchToExternal =
|
||||
e !== "external" || isExternalUrlsAllowed || question.buttonExternal;
|
||||
if (canSwitchToExternal) {
|
||||
updateQuestion(questionIdx, { buttonExternal: e === "external" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TooltipRenderer>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-between gap-8">
|
||||
|
||||
@@ -22,6 +22,7 @@ interface IDateQuestionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
const dateOptions = [
|
||||
@@ -49,6 +50,7 @@ export const DateQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: IDateQuestionFormProps): JSX.Element => {
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
const { t } = useTranslate();
|
||||
@@ -69,6 +71,7 @@ export const DateQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
@@ -87,6 +90,7 @@ export const DateQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { GripIcon, Handshake, Undo2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import {
|
||||
TSurvey,
|
||||
@@ -41,12 +40,12 @@ interface EditEndingCardProps {
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
plan: TOrganizationBillingPlan;
|
||||
addEndingCard: (index: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
isExternalUrlsAllowed: boolean;
|
||||
}
|
||||
|
||||
export const EditEndingCard = ({
|
||||
@@ -58,12 +57,12 @@ export const EditEndingCard = ({
|
||||
isInvalid,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
plan,
|
||||
addEndingCard,
|
||||
isFormbricksCloud,
|
||||
locale,
|
||||
isStorageConfigured,
|
||||
quotas,
|
||||
isExternalUrlsAllowed,
|
||||
}: EditEndingCardProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
@@ -73,7 +72,7 @@ export const EditEndingCard = ({
|
||||
);
|
||||
|
||||
const isRedirectToUrlDisabled = isFormbricksCloud
|
||||
? plan === "free" && endingCard.type !== "redirectToUrl"
|
||||
? !isExternalUrlsAllowed && endingCard.type !== "redirectToUrl"
|
||||
: false;
|
||||
|
||||
const [openDeleteConfirmationModal, setOpenDeleteConfirmationModal] = useState(false);
|
||||
@@ -281,7 +280,7 @@ export const EditEndingCard = ({
|
||||
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "mt-3 pb-6"}`}>
|
||||
<TooltipRenderer
|
||||
shouldRender={endingCard.type === "endScreen" && isRedirectToUrlDisabled}
|
||||
tooltipContent={t("environments.surveys.edit.redirect_to_url_not_available_on_free_plan")}
|
||||
tooltipContent={t("environments.surveys.edit.external_urls_paywall_tooltip")}
|
||||
triggerClass="w-full">
|
||||
<OptionsSwitch
|
||||
options={endingCardTypes}
|
||||
@@ -309,6 +308,7 @@ export const EditEndingCard = ({
|
||||
endingCard={endingCard}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
)}
|
||||
{endingCard.type === "redirectToUrl" && (
|
||||
|
||||
@@ -111,6 +111,7 @@ const defaultProps = {
|
||||
endingCard: defaultEndScreenCard,
|
||||
locale: "en-US" as TUserLocale,
|
||||
isStorageConfigured: true,
|
||||
isExternalUrlsAllowed: true,
|
||||
};
|
||||
|
||||
describe("EndScreenForm", () => {
|
||||
@@ -286,4 +287,38 @@ describe("EndScreenForm", () => {
|
||||
const buttonLabelInput = container.querySelector("#buttonLabel");
|
||||
expect(buttonLabelInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("disables buttonLink input when isExternalUrlsAllowed is false", () => {
|
||||
const propsWithDisabledUrl = {
|
||||
...defaultProps,
|
||||
endingCard: {
|
||||
...defaultEndScreenCard,
|
||||
buttonLabel: createI18nString("Click Me", ["en"]),
|
||||
buttonLink: "https://example.com",
|
||||
},
|
||||
isExternalUrlsAllowed: false,
|
||||
};
|
||||
|
||||
const { container } = render(<EndScreenForm {...propsWithDisabledUrl} />);
|
||||
|
||||
const buttonLinkInput = container.querySelector("#buttonLink") as HTMLInputElement;
|
||||
expect(buttonLinkInput).toBeInTheDocument();
|
||||
expect(buttonLinkInput).toBeDisabled();
|
||||
});
|
||||
|
||||
test("shows upgrade message when isExternalUrlsAllowed is false", () => {
|
||||
const propsWithDisabledUrl = {
|
||||
...defaultProps,
|
||||
endingCard: {
|
||||
...defaultEndScreenCard,
|
||||
buttonLabel: createI18nString("Click Me", ["en"]),
|
||||
buttonLink: undefined,
|
||||
},
|
||||
isExternalUrlsAllowed: false,
|
||||
};
|
||||
|
||||
const { getByText } = render(<EndScreenForm {...propsWithDisabledUrl} />);
|
||||
|
||||
expect(getByText("environments.surveys.edit.external_urls_paywall_tooltip")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ interface EndScreenFormProps {
|
||||
endingCard: TSurveyEndScreenCard;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed: boolean;
|
||||
}
|
||||
|
||||
export const EndScreenForm = ({
|
||||
@@ -36,6 +37,7 @@ export const EndScreenForm = ({
|
||||
endingCard,
|
||||
locale,
|
||||
isStorageConfigured,
|
||||
isExternalUrlsAllowed,
|
||||
}: EndScreenFormProps) => {
|
||||
const { t } = useTranslate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -184,7 +186,7 @@ export const EndScreenForm = ({
|
||||
ref={inputRef}
|
||||
id="buttonLink"
|
||||
name="buttonLink"
|
||||
className="relative text-black caret-black"
|
||||
className={`relative text-black caret-black ${!isExternalUrlsAllowed ? "cursor-not-allowed opacity-50" : ""}`}
|
||||
placeholder="https://formbricks.com"
|
||||
value={
|
||||
recallToHeadline(
|
||||
@@ -196,7 +198,8 @@ export const EndScreenForm = ({
|
||||
"default"
|
||||
)[selectedLanguageCode]
|
||||
}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(e) => isExternalUrlsAllowed && onChange(e.target.value)}
|
||||
disabled={!isExternalUrlsAllowed}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
@@ -204,6 +207,11 @@ export const EndScreenForm = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!isExternalUrlsAllowed && (
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("environments.surveys.edit.external_urls_paywall_tooltip")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -29,6 +29,7 @@ interface FileUploadFormProps {
|
||||
isFormbricksCloud: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const FileUploadQuestionForm = ({
|
||||
@@ -43,6 +44,7 @@ export const FileUploadQuestionForm = ({
|
||||
isFormbricksCloud,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: FileUploadFormProps): JSX.Element => {
|
||||
const [extension, setExtension] = useState("");
|
||||
const { t } = useTranslate();
|
||||
@@ -146,6 +148,7 @@ export const FileUploadQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
@@ -164,6 +167,7 @@ export const FileUploadQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ interface MatrixQuestionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const MatrixQuestionForm = ({
|
||||
@@ -41,6 +42,7 @@ export const MatrixQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: MatrixQuestionFormProps): JSX.Element => {
|
||||
const languageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
const { t } = useTranslate();
|
||||
@@ -201,6 +203,7 @@ export const MatrixQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
@@ -219,6 +222,7 @@ export const MatrixQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ interface MultipleChoiceQuestionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const MultipleChoiceQuestionForm = ({
|
||||
@@ -46,6 +47,7 @@ export const MultipleChoiceQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: MultipleChoiceQuestionFormProps): JSX.Element => {
|
||||
const { t } = useTranslate();
|
||||
const lastChoiceRef = useRef<HTMLInputElement>(null);
|
||||
@@ -211,6 +213,7 @@ export const MultipleChoiceQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -230,6 +233,7 @@ export const MultipleChoiceQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ interface NPSQuestionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const NPSQuestionForm = ({
|
||||
@@ -35,6 +36,7 @@ export const NPSQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: NPSQuestionFormProps): JSX.Element => {
|
||||
const { t } = useTranslate();
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
@@ -55,6 +57,7 @@ export const NPSQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -74,6 +77,7 @@ export const NPSQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ interface OpenQuestionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const OpenQuestionForm = ({
|
||||
@@ -41,6 +42,7 @@ export const OpenQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: OpenQuestionFormProps): JSX.Element => {
|
||||
const { t } = useTranslate();
|
||||
const questionTypes = [
|
||||
@@ -79,7 +81,7 @@ export const OpenQuestionForm = ({
|
||||
} else {
|
||||
setIsCharLimitEnabled(false);
|
||||
}
|
||||
}, []);
|
||||
}, [question?.charLimit?.max, question?.charLimit?.min]);
|
||||
|
||||
return (
|
||||
<form>
|
||||
@@ -96,6 +98,7 @@ export const OpenQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -115,6 +118,7 @@ export const OpenQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,7 @@ interface QuestionCardProps {
|
||||
responseCount: number;
|
||||
onAlertTrigger: () => void;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed: boolean;
|
||||
}
|
||||
|
||||
export const QuestionCard = ({
|
||||
@@ -88,6 +89,7 @@ export const QuestionCard = ({
|
||||
responseCount,
|
||||
onAlertTrigger,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: QuestionCardProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: question.id,
|
||||
@@ -294,6 +296,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
@@ -306,6 +309,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
@@ -318,6 +322,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.NPS ? (
|
||||
<NPSQuestionForm
|
||||
@@ -331,6 +336,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.CTA ? (
|
||||
<CTAQuestionForm
|
||||
@@ -344,6 +350,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Rating ? (
|
||||
<RatingQuestionForm
|
||||
@@ -357,6 +364,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Consent ? (
|
||||
<ConsentQuestionForm
|
||||
@@ -369,6 +377,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Date ? (
|
||||
<DateQuestionForm
|
||||
@@ -381,6 +390,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.PictureSelection ? (
|
||||
<PictureSelectionForm
|
||||
@@ -407,6 +417,7 @@ export const QuestionCard = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Cal ? (
|
||||
<CalQuestionForm
|
||||
@@ -420,6 +431,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Matrix ? (
|
||||
<MatrixQuestionForm
|
||||
@@ -432,6 +444,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Address ? (
|
||||
<AddressQuestionForm
|
||||
@@ -444,6 +457,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Ranking ? (
|
||||
<RankingQuestionForm
|
||||
@@ -456,6 +470,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.ContactInfo ? (
|
||||
<ContactInfoQuestionForm
|
||||
@@ -469,6 +484,7 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { QuestionCard } from "@/modules/survey/editor/components/question-card";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { Project } from "@prisma/client";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { QuestionCard } from "@/modules/survey/editor/components/question-card";
|
||||
|
||||
interface QuestionsDraggableProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -24,6 +24,7 @@ interface QuestionsDraggableProps {
|
||||
responseCount: number;
|
||||
onAlertTrigger: () => void;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed: boolean;
|
||||
}
|
||||
|
||||
export const QuestionsDroppable = ({
|
||||
@@ -45,6 +46,7 @@ export const QuestionsDroppable = ({
|
||||
responseCount,
|
||||
onAlertTrigger,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: QuestionsDraggableProps) => {
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
@@ -75,6 +77,7 @@ export const QuestionsDroppable = ({
|
||||
responseCount={responseCount}
|
||||
onAlertTrigger={onAlertTrigger}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
@@ -15,16 +15,16 @@ import { Language, Project } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import React, { SetStateAction, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TSingleCondition,
|
||||
TSurvey,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionId,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
|
||||
@@ -61,13 +61,13 @@ interface QuestionsViewProps {
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
isMultiLanguageAllowed?: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
plan: TOrganizationBillingPlan;
|
||||
isCxMode: boolean;
|
||||
locale: TUserLocale;
|
||||
responseCount: number;
|
||||
setIsCautionDialogOpen: (open: boolean) => void;
|
||||
isStorageConfigured: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
isExternalUrlsAllowed: boolean;
|
||||
}
|
||||
|
||||
export const QuestionsView = ({
|
||||
@@ -83,13 +83,13 @@ export const QuestionsView = ({
|
||||
selectedLanguageCode,
|
||||
isMultiLanguageAllowed,
|
||||
isFormbricksCloud,
|
||||
plan,
|
||||
isCxMode,
|
||||
locale,
|
||||
responseCount,
|
||||
setIsCautionDialogOpen,
|
||||
isStorageConfigured = true,
|
||||
quotas,
|
||||
isExternalUrlsAllowed,
|
||||
}: QuestionsViewProps) => {
|
||||
const { t } = useTranslate();
|
||||
const internalQuestionIdMap = useMemo(() => {
|
||||
@@ -495,6 +495,7 @@ export const QuestionsView = ({
|
||||
responseCount={responseCount}
|
||||
onAlertTrigger={() => setIsCautionDialogOpen(true)}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</DndContext>
|
||||
|
||||
@@ -519,12 +520,12 @@ export const QuestionsView = ({
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(ending.id) : false}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
plan={plan}
|
||||
addEndingCard={addEndingCard}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
quotas={quotas}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -26,6 +26,7 @@ interface RankingQuestionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const RankingQuestionForm = ({
|
||||
@@ -38,6 +39,7 @@ export const RankingQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: RankingQuestionFormProps): JSX.Element => {
|
||||
const { t } = useTranslate();
|
||||
const lastChoiceRef = useRef<HTMLInputElement>(null);
|
||||
@@ -132,6 +134,7 @@ export const RankingQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -151,6 +154,7 @@ export const RankingQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ interface RatingQuestionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const RatingQuestionForm = ({
|
||||
@@ -35,6 +36,7 @@ export const RatingQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: RatingQuestionFormProps) => {
|
||||
const { t } = useTranslate();
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
@@ -55,6 +57,7 @@ export const RatingQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -74,6 +77,7 @@ export const RatingQuestionForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ActionClass, Environment, Language, OrganizationRole, Project } from "@prisma/client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { useDocumentVisibility } from "@/lib/useDocumentVisibility";
|
||||
@@ -14,14 +21,6 @@ import { SurveyMenuBar } from "@/modules/survey/editor/components/survey-menu-ba
|
||||
import { TFollowUpEmailToUser } from "@/modules/survey/editor/types/survey-follow-up";
|
||||
import { FollowUpsView } from "@/modules/survey/follow-ups/components/follow-ups-view";
|
||||
import { PreviewSurvey } from "@/modules/ui/components/preview-survey";
|
||||
import { ActionClass, Environment, Language, OrganizationRole, Project } from "@prisma/client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { refetchProjectAction } from "../actions";
|
||||
|
||||
interface SurveyEditorProps {
|
||||
@@ -49,8 +48,8 @@ interface SurveyEditorProps {
|
||||
userEmail: string;
|
||||
teamMemberDetails: TFollowUpEmailToUser[];
|
||||
isStorageConfigured: boolean;
|
||||
plan: TOrganizationBillingPlan;
|
||||
quotas: TSurveyQuota[];
|
||||
isExternalUrlsAllowed: boolean;
|
||||
}
|
||||
|
||||
export const SurveyEditor = ({
|
||||
@@ -72,7 +71,6 @@ export const SurveyEditor = ({
|
||||
isQuotasAllowed,
|
||||
isCxMode = false,
|
||||
locale,
|
||||
plan,
|
||||
projectPermission,
|
||||
mailFrom,
|
||||
isSurveyFollowUpsAllowed = false,
|
||||
@@ -80,10 +78,12 @@ export const SurveyEditor = ({
|
||||
teamMemberDetails,
|
||||
isStorageConfigured,
|
||||
quotas,
|
||||
isExternalUrlsAllowed,
|
||||
}: SurveyEditorProps) => {
|
||||
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>(() => structuredClone(survey));
|
||||
const [savedSurvey, setSavedSurvey] = useState<TSurvey>(survey);
|
||||
const [invalidQuestions, setInvalidQuestions] = useState<string[] | null>(null);
|
||||
const [selectedLanguageCode, setSelectedLanguageCode] = useState<string>("default");
|
||||
const surveyEditorRef = useRef(null);
|
||||
@@ -109,6 +109,7 @@ export const SurveyEditor = ({
|
||||
|
||||
const surveyClone = structuredClone(survey);
|
||||
setLocalSurvey(surveyClone);
|
||||
setSavedSurvey(surveyClone);
|
||||
|
||||
if (survey.questions.length > 0) {
|
||||
setActiveQuestionId(survey.questions[0].id);
|
||||
@@ -161,8 +162,9 @@ export const SurveyEditor = ({
|
||||
<SurveyMenuBar
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
localSurvey={localSurvey}
|
||||
survey={survey}
|
||||
survey={savedSurvey}
|
||||
environmentId={environment.id}
|
||||
setSavedSurvey={setSavedSurvey}
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
setInvalidQuestions={setInvalidQuestions}
|
||||
@@ -197,17 +199,17 @@ export const SurveyEditor = ({
|
||||
projectLanguages={projectLanguages}
|
||||
invalidQuestions={invalidQuestions}
|
||||
setInvalidQuestions={setInvalidQuestions}
|
||||
selectedLanguageCode={selectedLanguageCode ? selectedLanguageCode : "default"}
|
||||
selectedLanguageCode={selectedLanguageCode || "default"}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
plan={plan}
|
||||
isCxMode={isCxMode}
|
||||
locale={locale}
|
||||
responseCount={responseCount}
|
||||
setIsCautionDialogOpen={setIsCautionDialogOpen}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
quotas={quotas}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Project } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { isEqual } from "lodash";
|
||||
import { ArrowLeftIcon, SettingsIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import toast from "react-hot-toast";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
@@ -23,11 +18,18 @@ import {
|
||||
ZSurveyEndScreenCard,
|
||||
ZSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { updateSurveyAction } from "../actions";
|
||||
import { isSurveyValid } from "../lib/validation";
|
||||
|
||||
interface SurveyMenuBarProps {
|
||||
localSurvey: TSurvey;
|
||||
setSavedSurvey: (survey: TSurvey) => void;
|
||||
survey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
environmentId: string;
|
||||
@@ -47,6 +49,7 @@ interface SurveyMenuBarProps {
|
||||
export const SurveyMenuBar = ({
|
||||
localSurvey,
|
||||
survey,
|
||||
setSavedSurvey,
|
||||
environmentId,
|
||||
setLocalSurvey,
|
||||
activeId,
|
||||
@@ -247,9 +250,12 @@ export const SurveyMenuBar = ({
|
||||
|
||||
setIsSurveySaving(false);
|
||||
if (updatedSurveyResponse?.data) {
|
||||
setLocalSurvey(updatedSurveyResponse.data);
|
||||
const updatedSurvey = updatedSurveyResponse.data;
|
||||
flushSync(() => {
|
||||
setLocalSurvey(updatedSurvey);
|
||||
setSavedSurvey(updatedSurvey);
|
||||
});
|
||||
toast.success(t("environments.surveys.edit.changes_saved"));
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
|
||||
toast.error(errorMessage);
|
||||
@@ -292,12 +298,18 @@ export const SurveyMenuBar = ({
|
||||
const segment = await handleSegmentUpdate();
|
||||
clearSurveyLocalStorage();
|
||||
|
||||
await updateSurveyAction({
|
||||
const publishedSurveyResponse = await updateSurveyAction({
|
||||
...localSurvey,
|
||||
status,
|
||||
segment,
|
||||
});
|
||||
setIsSurveyPublishing(false);
|
||||
if (publishedSurveyResponse?.data) {
|
||||
const publishedSurvey = publishedSurveyResponse.data;
|
||||
flushSync(() => {
|
||||
setSavedSurvey(publishedSurvey);
|
||||
});
|
||||
}
|
||||
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { checkExternalUrlsPermission } from "./check-external-urls-permission";
|
||||
|
||||
vi.mock("@/modules/survey/lib/survey", () => ({
|
||||
getOrganizationBilling: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/permission", () => ({
|
||||
getExternalUrlsPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const { getOrganizationBilling } = await import("@/modules/survey/lib/survey");
|
||||
const { getExternalUrlsPermission } = await import("@/modules/survey/lib/permission");
|
||||
|
||||
describe("checkExternalUrlsPermission", () => {
|
||||
const mockOrganizationId = "org123";
|
||||
const baseSurvey: TSurvey = {
|
||||
id: "survey123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
type: "link",
|
||||
environmentId: "env123",
|
||||
createdBy: "user123",
|
||||
status: "draft",
|
||||
displayOption: "displayOnce",
|
||||
questions: [],
|
||||
endings: [],
|
||||
hiddenFields: { enabled: false },
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
projectOverwrites: null,
|
||||
styling: null,
|
||||
showLanguageSwitch: false,
|
||||
segment: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
isVerifyEmailEnabled: false,
|
||||
recaptcha: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
pin: null,
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
variables: [],
|
||||
followUps: [],
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
timeToFinish: true,
|
||||
showResponseCount: false,
|
||||
},
|
||||
triggers: [],
|
||||
metadata: {},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockOrganizationBilling = {
|
||||
id: mockOrganizationId,
|
||||
plan: "free",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when organization billing is not found", async () => {
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(null);
|
||||
|
||||
await expect(checkExternalUrlsPermission(mockOrganizationId, baseSurvey, null)).rejects.toThrow(
|
||||
ResourceNotFoundError
|
||||
);
|
||||
expect(getOrganizationBilling).toHaveBeenCalledWith(mockOrganizationId);
|
||||
});
|
||||
|
||||
test("should allow external URLs when permission is granted", async () => {
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
||||
|
||||
const surveyWithExternalUrl: TSurvey = {
|
||||
...baseSurvey,
|
||||
endings: [
|
||||
{
|
||||
id: "end1",
|
||||
type: "endScreen",
|
||||
headline: createI18nString("Thank you", ["en"]),
|
||||
buttonLink: "https://example.com",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(
|
||||
checkExternalUrlsPermission(mockOrganizationId, surveyWithExternalUrl, null)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test("should throw OperationNotAllowedError for new ending card button link without permission", async () => {
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
|
||||
const surveyWithNewButtonLink: TSurvey = {
|
||||
...baseSurvey,
|
||||
endings: [
|
||||
{
|
||||
id: "end1",
|
||||
type: "endScreen",
|
||||
headline: createI18nString("Thank you", ["en"]),
|
||||
buttonLink: "https://example.com",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(
|
||||
checkExternalUrlsPermission(mockOrganizationId, surveyWithNewButtonLink, null)
|
||||
).rejects.toThrow(OperationNotAllowedError);
|
||||
await expect(
|
||||
checkExternalUrlsPermission(mockOrganizationId, surveyWithNewButtonLink, null)
|
||||
).rejects.toThrow("External URLs are not enabled for this organization");
|
||||
});
|
||||
|
||||
test("should allow unchanged ending card button link (grandfathering)", async () => {
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
|
||||
const oldSurvey: TSurvey = {
|
||||
...baseSurvey,
|
||||
endings: [
|
||||
{
|
||||
id: "end1",
|
||||
type: "endScreen",
|
||||
headline: createI18nString("Thank you", ["en"]),
|
||||
buttonLink: "https://example.com",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newSurvey: TSurvey = {
|
||||
...baseSurvey,
|
||||
endings: [
|
||||
{
|
||||
id: "end1",
|
||||
type: "endScreen",
|
||||
headline: createI18nString("Thank you very much", ["en"]),
|
||||
buttonLink: "https://example.com",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(
|
||||
checkExternalUrlsPermission(mockOrganizationId, newSurvey, oldSurvey)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test("should throw OperationNotAllowedError for changed ending card button link without permission", async () => {
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
|
||||
const oldSurvey: TSurvey = {
|
||||
...baseSurvey,
|
||||
endings: [
|
||||
{
|
||||
id: "end1",
|
||||
type: "endScreen",
|
||||
headline: createI18nString("Thank you", ["en"]),
|
||||
buttonLink: "https://example.com",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newSurvey: TSurvey = {
|
||||
...baseSurvey,
|
||||
endings: [
|
||||
{
|
||||
id: "end1",
|
||||
type: "endScreen",
|
||||
headline: createI18nString("Thank you", ["en"]),
|
||||
buttonLink: "https://different-url.com",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(checkExternalUrlsPermission(mockOrganizationId, newSurvey, oldSurvey)).rejects.toThrow(
|
||||
OperationNotAllowedError
|
||||
);
|
||||
});
|
||||
|
||||
test("should allow ending card without button link", async () => {
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
|
||||
const surveyWithoutButtonLink: TSurvey = {
|
||||
...baseSurvey,
|
||||
endings: [
|
||||
{
|
||||
id: "end1",
|
||||
type: "endScreen",
|
||||
headline: createI18nString("Thank you", ["en"]),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(
|
||||
checkExternalUrlsPermission(mockOrganizationId, surveyWithoutButtonLink, null)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test("should throw OperationNotAllowedError for new external CTA button without permission", async () => {
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
|
||||
const surveyWithExternalCTA: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: createI18nString("Click here", ["en"]),
|
||||
buttonLabel: createI18nString("Visit", ["en"]),
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(
|
||||
checkExternalUrlsPermission(mockOrganizationId, surveyWithExternalCTA, null)
|
||||
).rejects.toThrow(OperationNotAllowedError);
|
||||
await expect(
|
||||
checkExternalUrlsPermission(mockOrganizationId, surveyWithExternalCTA, null)
|
||||
).rejects.toThrow("External URLs are not enabled for this organization");
|
||||
});
|
||||
|
||||
test("should allow unchanged external CTA button (grandfathering)", async () => {
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
|
||||
const oldSurvey: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: createI18nString("Click here", ["en"]),
|
||||
buttonLabel: createI18nString("Visit", ["en"]),
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newSurvey: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: createI18nString("Click here now", ["en"]),
|
||||
buttonLabel: createI18nString("Visit", ["en"]),
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(
|
||||
checkExternalUrlsPermission(mockOrganizationId, newSurvey, oldSurvey)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test("should throw OperationNotAllowedError when switching CTA button to external without permission", async () => {
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
|
||||
const oldSurvey: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: createI18nString("Click here", ["en"]),
|
||||
buttonLabel: createI18nString("Visit", ["en"]),
|
||||
buttonExternal: false,
|
||||
buttonUrl: "",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newSurvey: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: createI18nString("Click here", ["en"]),
|
||||
buttonLabel: createI18nString("Visit", ["en"]),
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(checkExternalUrlsPermission(mockOrganizationId, newSurvey, oldSurvey)).rejects.toThrow(
|
||||
OperationNotAllowedError
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw OperationNotAllowedError when changing external CTA button URL without permission", async () => {
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
|
||||
const oldSurvey: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: createI18nString("Click here", ["en"]),
|
||||
buttonLabel: createI18nString("Visit", ["en"]),
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newSurvey: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: createI18nString("Click here", ["en"]),
|
||||
buttonLabel: createI18nString("Visit", ["en"]),
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://different-url.com",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(checkExternalUrlsPermission(mockOrganizationId, newSurvey, oldSurvey)).rejects.toThrow(
|
||||
OperationNotAllowedError
|
||||
);
|
||||
});
|
||||
|
||||
test("should allow internal CTA button without permission", async () => {
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
|
||||
const surveyWithInternalCTA: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: createI18nString("Click here", ["en"]),
|
||||
buttonLabel: createI18nString("Visit", ["en"]),
|
||||
buttonExternal: false,
|
||||
buttonUrl: "",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(
|
||||
checkExternalUrlsPermission(mockOrganizationId, surveyWithInternalCTA, null)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test("should handle surveys with multiple questions and endings", async () => {
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
|
||||
const complexSurvey: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: createI18nString("Question 1", ["en"]),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: createI18nString("Click here", ["en"]),
|
||||
buttonLabel: createI18nString("Visit", ["en"]),
|
||||
buttonExternal: false,
|
||||
buttonUrl: "",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
{
|
||||
id: "end1",
|
||||
type: "endScreen",
|
||||
headline: createI18nString("Thank you", ["en"]),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(checkExternalUrlsPermission(mockOrganizationId, complexSurvey, null)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
|
||||
/**
|
||||
* Checks if external URLs can be added or modified for the given organization.
|
||||
* Grandfathers existing external URLs (allows keeping them even on free plan).
|
||||
*
|
||||
* @param { string } organizationId The ID of the organization to check.
|
||||
* @param { TSurvey } newSurvey The new survey state.
|
||||
* @param { TSurvey | null } oldSurvey The old survey state (or null for new surveys).
|
||||
* @returns { Promise<void> } A promise that resolves if the permission is granted.
|
||||
* @throws { ResourceNotFoundError } If the organization is not found.
|
||||
* @throws { OperationNotAllowedError } If external URLs are not allowed and new/changed URLs are detected.
|
||||
*/
|
||||
export const checkExternalUrlsPermission = async (
|
||||
organizationId: string,
|
||||
newSurvey: TSurvey,
|
||||
oldSurvey: TSurvey | null
|
||||
): Promise<void> => {
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
const isExternalUrlsAllowed = await getExternalUrlsPermission(organizationBilling.plan);
|
||||
if (isExternalUrlsAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check ending cards for new/changed button links
|
||||
for (const newEnding of newSurvey.endings) {
|
||||
const oldEnding = oldSurvey?.endings.find((e) => e.id === newEnding.id);
|
||||
|
||||
if (newEnding.type === "endScreen" && newEnding.buttonLink) {
|
||||
if (!oldEnding || oldEnding.type !== "endScreen" || oldEnding.buttonLink !== newEnding.buttonLink) {
|
||||
throw new OperationNotAllowedError(
|
||||
"External URLs are not enabled for this organization. Upgrade to use external button links."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check CTA questions for new/changed external button URLs
|
||||
for (const newQuestion of newSurvey.questions) {
|
||||
const oldQuestion = oldSurvey?.questions.find((q) => q.id === newQuestion.id);
|
||||
|
||||
if (newQuestion.type === "cta" && newQuestion.buttonExternal) {
|
||||
if (
|
||||
!oldQuestion ||
|
||||
oldQuestion.type !== "cta" ||
|
||||
!oldQuestion.buttonExternal ||
|
||||
oldQuestion.buttonUrl !== newQuestion.buttonUrl
|
||||
) {
|
||||
throw new OperationNotAllowedError(
|
||||
"External URLs are not enabled for this organization. Upgrade to use external CTA buttons."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { getTeamMemberDetails } from "@/modules/survey/editor/lib/team";
|
||||
import { getUserEmail } from "@/modules/survey/editor/lib/user";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
||||
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
|
||||
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
|
||||
import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
|
||||
@@ -71,13 +72,19 @@ export const SurveyEditorPage = async (props) => {
|
||||
]);
|
||||
|
||||
const isUserTargetingAllowed = await getIsContactsEnabled();
|
||||
const [isMultiLanguageAllowed, isSurveyFollowUpsAllowed, isSpamProtectionAllowed, isQuotasAllowed] =
|
||||
await Promise.all([
|
||||
getMultiLanguagePermission(organizationBilling.plan),
|
||||
getSurveyFollowUpsPermission(organizationBilling.plan),
|
||||
getIsSpamProtectionEnabled(organizationBilling.plan),
|
||||
getIsQuotasEnabled(organizationBilling.plan),
|
||||
]);
|
||||
const [
|
||||
isMultiLanguageAllowed,
|
||||
isSurveyFollowUpsAllowed,
|
||||
isSpamProtectionAllowed,
|
||||
isQuotasAllowed,
|
||||
isExternalUrlsAllowed,
|
||||
] = await Promise.all([
|
||||
getMultiLanguagePermission(organizationBilling.plan),
|
||||
getSurveyFollowUpsPermission(organizationBilling.plan),
|
||||
getIsSpamProtectionEnabled(organizationBilling.plan),
|
||||
getIsQuotasEnabled(organizationBilling.plan),
|
||||
getExternalUrlsPermission(organizationBilling.plan),
|
||||
]);
|
||||
|
||||
const quotas = isQuotasAllowed && survey ? await getQuotas(survey.id) : [];
|
||||
const [projectLanguages, teamMemberDetails] = await Promise.all([
|
||||
@@ -115,7 +122,6 @@ export const SurveyEditorPage = async (props) => {
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isSpamProtectionAllowed={isSpamProtectionAllowed}
|
||||
projectLanguages={projectLanguages}
|
||||
plan={organizationBilling.plan}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
|
||||
isCxMode={isCxMode}
|
||||
@@ -127,6 +133,7 @@ export const SurveyEditorPage = async (props) => {
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { Organization } from "@prisma/client";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { checkSpamProtectionPermission } from "./permission";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { checkSpamProtectionPermission, getExternalUrlsPermission } from "./permission";
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsSpamProtectionEnabled: vi.fn(),
|
||||
@@ -14,6 +14,16 @@ vi.mock("@/modules/survey/lib/survey", () => ({
|
||||
getOrganizationBilling: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
PROJECT_FEATURE_KEYS: {
|
||||
FREE: "free",
|
||||
PRO: "pro",
|
||||
ENTERPRISE: "enterprise",
|
||||
SCALE: "scale",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("checkSpamProtectionPermission", () => {
|
||||
const mockOrganizationId = "mock-organization-id";
|
||||
const mockBillingData: Organization["billing"] = {
|
||||
@@ -51,3 +61,117 @@ describe("checkSpamProtectionPermission", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getExternalUrlsPermission - Formbricks Cloud", () => {
|
||||
test("should return false for free plan in Formbricks Cloud", async () => {
|
||||
const result = await getExternalUrlsPermission("free");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for pro plan in Formbricks Cloud", async () => {
|
||||
const result = await getExternalUrlsPermission("pro");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for enterprise plan in Formbricks Cloud", async () => {
|
||||
const result = await getExternalUrlsPermission("enterprise");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for scale plan in Formbricks Cloud", async () => {
|
||||
const result = await getExternalUrlsPermission("scale");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for any non-free plan string in Formbricks Cloud", async () => {
|
||||
const result = await getExternalUrlsPermission("custom-plan");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getExternalUrlsPermission - Self-hosted", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("should return true for free plan in self-hosted", async () => {
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
PROJECT_FEATURE_KEYS: {
|
||||
FREE: "free",
|
||||
PRO: "pro",
|
||||
ENTERPRISE: "enterprise",
|
||||
SCALE: "scale",
|
||||
},
|
||||
}));
|
||||
|
||||
const { getExternalUrlsPermission: getExternalUrlsPermissionSelfHosted } = await import("./permission");
|
||||
const result = await getExternalUrlsPermissionSelfHosted("free");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for pro plan in self-hosted", async () => {
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
PROJECT_FEATURE_KEYS: {
|
||||
FREE: "free",
|
||||
PRO: "pro",
|
||||
ENTERPRISE: "enterprise",
|
||||
SCALE: "scale",
|
||||
},
|
||||
}));
|
||||
|
||||
const { getExternalUrlsPermission: getExternalUrlsPermissionSelfHosted } = await import("./permission");
|
||||
const result = await getExternalUrlsPermissionSelfHosted("pro");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for enterprise plan in self-hosted", async () => {
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
PROJECT_FEATURE_KEYS: {
|
||||
FREE: "free",
|
||||
PRO: "pro",
|
||||
ENTERPRISE: "enterprise",
|
||||
SCALE: "scale",
|
||||
},
|
||||
}));
|
||||
|
||||
const { getExternalUrlsPermission: getExternalUrlsPermissionSelfHosted } = await import("./permission");
|
||||
const result = await getExternalUrlsPermissionSelfHosted("enterprise");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for scale plan in self-hosted", async () => {
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
PROJECT_FEATURE_KEYS: {
|
||||
FREE: "free",
|
||||
PRO: "pro",
|
||||
ENTERPRISE: "enterprise",
|
||||
SCALE: "scale",
|
||||
},
|
||||
}));
|
||||
|
||||
const { getExternalUrlsPermission: getExternalUrlsPermissionSelfHosted } = await import("./permission");
|
||||
const result = await getExternalUrlsPermissionSelfHosted("scale");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for any plan in self-hosted", async () => {
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
PROJECT_FEATURE_KEYS: {
|
||||
FREE: "free",
|
||||
PRO: "pro",
|
||||
ENTERPRISE: "enterprise",
|
||||
SCALE: "scale",
|
||||
},
|
||||
}));
|
||||
|
||||
const { getExternalUrlsPermission: getExternalUrlsPermissionSelfHosted } = await import("./permission");
|
||||
const result = await getExternalUrlsPermissionSelfHosted("custom-plan");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Organization } from "@prisma/client";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD, PROJECT_FEATURE_KEYS } from "@/lib/constants";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
/**
|
||||
* Checks if the organization has spam protection enabled.
|
||||
@@ -20,3 +22,10 @@ export const checkSpamProtectionPermission = async (organizationId: string): Pro
|
||||
throw new OperationNotAllowedError("Spam protection is not enabled for this organization");
|
||||
}
|
||||
};
|
||||
|
||||
export const getExternalUrlsPermission = async (
|
||||
billingPlan: Organization["billing"]["plan"]
|
||||
): Promise<boolean> => {
|
||||
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -113,14 +113,32 @@ describe("Editor", () => {
|
||||
expect(screen.getByTestId("toolbar-plugin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("rich-text-plugin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("list-plugin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("link-plugin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("auto-link-plugin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("markdown-plugin")).toBeInTheDocument();
|
||||
|
||||
// Link plugins should not be rendered by default (isExternalUrlsAllowed is undefined/false)
|
||||
expect(screen.queryByTestId("link-plugin")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("auto-link-plugin")).not.toBeInTheDocument();
|
||||
|
||||
// Editor should be editable by default
|
||||
expect(screen.getByTestId("lexical-composer")).toHaveAttribute("data-editable", "true");
|
||||
});
|
||||
|
||||
test("renders link plugins when isExternalUrlsAllowed is true", () => {
|
||||
render(<Editor getText={() => "Sample text"} setText={() => {}} isExternalUrlsAllowed={true} />);
|
||||
|
||||
// Link plugins should be rendered when external URLs are allowed
|
||||
expect(screen.getByTestId("link-plugin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("auto-link-plugin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render link plugins when isExternalUrlsAllowed is false", () => {
|
||||
render(<Editor getText={() => "Sample text"} setText={() => {}} isExternalUrlsAllowed={false} />);
|
||||
|
||||
// Link plugins should not be rendered when external URLs are not allowed
|
||||
expect(screen.queryByTestId("link-plugin")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("auto-link-plugin")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the editor with custom height", () => {
|
||||
render(<Editor getText={() => "Sample text"} setText={() => {}} height="200px" />);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CodeHighlightNode, CodeNode } from "@lexical/code";
|
||||
import { AutoLinkNode, LinkNode } from "@lexical/link";
|
||||
import { ListItemNode, ListNode } from "@lexical/list";
|
||||
import { TRANSFORMERS } from "@lexical/markdown";
|
||||
import { LINK, TRANSFORMERS } from "@lexical/markdown";
|
||||
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
|
||||
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
||||
@@ -57,6 +57,7 @@ export type TextEditorProps = {
|
||||
addFallback?: () => void;
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
};
|
||||
|
||||
const editorConfig = {
|
||||
@@ -114,6 +115,7 @@ export const Editor = (props: TextEditorProps) => {
|
||||
recallItemsCount={recallItems.length}
|
||||
setShowFallbackInput={setShowFallbackInput}
|
||||
setShowLinkEditor={setShowLinkEditor}
|
||||
isExternalUrlsAllowed={props.isExternalUrlsAllowed}
|
||||
/>
|
||||
{props.onEmptyChange ? <EditorContentChecker onEmptyChange={props.onEmptyChange} /> : null}
|
||||
<div
|
||||
@@ -136,8 +138,8 @@ export const Editor = (props: TextEditorProps) => {
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<ListPlugin />
|
||||
<LinkPlugin />
|
||||
<AutoLinkPlugin />
|
||||
{props.isExternalUrlsAllowed && <LinkPlugin />}
|
||||
{props.isExternalUrlsAllowed && <AutoLinkPlugin />}
|
||||
{props.autoFocus && <AutoFocusPlugin />}
|
||||
{props.localSurvey && props.questionId && props.selectedLanguageCode && (
|
||||
<RecallPlugin
|
||||
@@ -160,8 +162,8 @@ export const Editor = (props: TextEditorProps) => {
|
||||
props.disableLists
|
||||
? TRANSFORMERS.filter((value, index) => {
|
||||
if (index !== 3 && index !== 4) return value;
|
||||
})
|
||||
: TRANSFORMERS
|
||||
}).filter((t) => (props.isExternalUrlsAllowed ? true : t !== LINK))
|
||||
: TRANSFORMERS.filter((t) => (props.isExternalUrlsAllowed ? true : t !== LINK))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -274,6 +274,8 @@ export const ToolbarPlugin = (
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -315,6 +317,14 @@ export const ToolbarPlugin = (
|
||||
|
||||
if (!props.editable) return <></>;
|
||||
|
||||
const getLinkItemTooltipText = () => {
|
||||
if (!props.isExternalUrlsAllowed) {
|
||||
return t("environments.surveys.edit.external_urls_paywall_tooltip");
|
||||
}
|
||||
|
||||
return isLink ? t("environments.surveys.edit.edit_link") : t("environments.surveys.edit.insert_link");
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: "bold",
|
||||
@@ -345,10 +355,8 @@ export const ToolbarPlugin = (
|
||||
icon: Link,
|
||||
onClick: insertLink,
|
||||
active: isLink,
|
||||
tooltipText: isLink
|
||||
? t("environments.surveys.edit.edit_link")
|
||||
: t("environments.surveys.edit.insert_link"),
|
||||
disabled: !isLink && !hasTextSelection,
|
||||
tooltipText: getLinkItemTooltipText(),
|
||||
disabled: !props.isExternalUrlsAllowed || (!isLink && !hasTextSelection),
|
||||
},
|
||||
{
|
||||
key: "recall",
|
||||
|
||||
@@ -2340,6 +2340,50 @@
|
||||
"name": "My Action from Postman",
|
||||
"type": "code"
|
||||
},
|
||||
"properties": {
|
||||
"description": {
|
||||
"description": "Optional description of the action class",
|
||||
"type": "string"
|
||||
},
|
||||
"environmentId": {
|
||||
"description": "The environment ID where the action class will be created",
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"description": "Required when type is 'code'. A unique identifier for the action. Not needed for 'noCode' type.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Name of the action class",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"noCodeConfig": {
|
||||
"description": "Configuration object required when type is 'noCode'. Defines the conditions for triggering the action. Not needed for 'code' type.",
|
||||
"example": {
|
||||
"elementSelector": {
|
||||
"cssSelector": ".button-class",
|
||||
"innerHtml": "Click me"
|
||||
},
|
||||
"type": "click",
|
||||
"urlFilters": [
|
||||
{
|
||||
"rule": "contains",
|
||||
"value": "https://www.google.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": true,
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"description": "Type of action class",
|
||||
"enum": ["code", "noCode"],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["environmentId", "name", "type"],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
@@ -2417,8 +2461,11 @@
|
||||
"application/json": {
|
||||
"example": {
|
||||
"code": "bad_request",
|
||||
"details": {},
|
||||
"message": "Database error when creating an action for environment clurwouax000azffxt7n5unn3"
|
||||
"details": {
|
||||
"environmentId": "Required",
|
||||
"key": "Required"
|
||||
},
|
||||
"message": "Fields are missing or incorrectly formatted"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object"
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This is a better (faster) alternative to the built-in Nix support
|
||||
if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4="
|
||||
fi
|
||||
|
||||
use flake
|
||||
@@ -1,3 +0,0 @@
|
||||
.terraform/
|
||||
builds
|
||||
/.direnv/
|
||||
Generated
-61
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1754767907,
|
||||
"narHash": "sha256-8OnUzRQZkqtUol9vuUuQC30hzpMreKptNyET2T9lB6g=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c5f08b62ed75415439d48152c2a784e36909b1bc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
in
|
||||
with pkgs;
|
||||
{
|
||||
devShells.default = mkShell {
|
||||
buildInputs = [
|
||||
awscli
|
||||
terraform
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
repositories:
|
||||
- name: helm-charts
|
||||
url: ghcr.io/formbricks/helm-charts
|
||||
oci: true
|
||||
|
||||
releases:
|
||||
- name: formbricks
|
||||
namespace: formbricks
|
||||
chart: helm-charts/formbricks
|
||||
version: ^3.0.0
|
||||
values:
|
||||
- values.yaml.gotmpl
|
||||
set:
|
||||
- name: deployment.image.tag
|
||||
value: {{ requiredEnv "VERSION" }}
|
||||
- name: deployment.image.repository
|
||||
value: {{ requiredEnv "REPOSITORY" }}
|
||||
labels:
|
||||
environment: prod
|
||||
- name: formbricks-stage
|
||||
namespace: formbricks-stage
|
||||
chart: helm-charts/formbricks
|
||||
version: ^3.0.0
|
||||
values:
|
||||
- values-staging.yaml.gotmpl
|
||||
createNamespace: true
|
||||
set:
|
||||
- name: deployment.image.tag
|
||||
value: {{ requiredEnv "VERSION" }}
|
||||
- name: deployment.image.repository
|
||||
value: {{ requiredEnv "REPOSITORY" }}
|
||||
labels:
|
||||
environment: stage
|
||||
@@ -1,91 +0,0 @@
|
||||
nameOverride: "formbricks-stage"
|
||||
## Deployment & Autoscaling
|
||||
deployment:
|
||||
image:
|
||||
pullPolicy: Always
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 1
|
||||
memory: 1Gi
|
||||
env:
|
||||
RATE_LIMITING_DISABLED:
|
||||
value: "1"
|
||||
envFrom:
|
||||
app-env:
|
||||
nameSuffix: app-env
|
||||
type: secret
|
||||
nodeSelector:
|
||||
karpenter.sh/capacity-type: spot
|
||||
reloadOnChange: true
|
||||
autoscaling:
|
||||
enabled: true
|
||||
maxReplicas: 95
|
||||
minReplicas: 3
|
||||
metrics:
|
||||
- resource:
|
||||
name: cpu
|
||||
target:
|
||||
averageUtilization: 60
|
||||
type: Utilization
|
||||
type: Resource
|
||||
- resource:
|
||||
name: memory
|
||||
target:
|
||||
averageUtilization: 60
|
||||
type: Utilization
|
||||
type: Resource
|
||||
|
||||
### Secrets
|
||||
secret:
|
||||
enabled: false
|
||||
externalSecret:
|
||||
enabled: true
|
||||
files:
|
||||
app-env:
|
||||
dataFrom:
|
||||
key: stage/formbricks/environment
|
||||
app-secrets:
|
||||
dataFrom:
|
||||
key: stage/formbricks/secrets
|
||||
refreshInterval: 1m
|
||||
secretStore:
|
||||
kind: ClusterSecretStore
|
||||
name: aws-secrets-manager
|
||||
|
||||
## Ingress
|
||||
ingress:
|
||||
annotations:
|
||||
alb.ingress.kubernetes.io/certificate-arn: {{ requiredEnv "FORMBRICKS_INGRESS_CERT_ARN" }}
|
||||
alb.ingress.kubernetes.io/group.name: internal
|
||||
alb.ingress.kubernetes.io/healthcheck-path: /health
|
||||
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
|
||||
alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-Res-2021-06
|
||||
alb.ingress.kubernetes.io/ssl-redirect: "443"
|
||||
alb.ingress.kubernetes.io/target-type: ip
|
||||
enabled: true
|
||||
hosts:
|
||||
- host: stage.app.formbricks.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
serviceName: formbricks-stage
|
||||
ingressClassName: alb
|
||||
|
||||
## RBAC
|
||||
rbac:
|
||||
enabled: true
|
||||
serviceAccount:
|
||||
annotations:
|
||||
eks.amazonaws.com/role-arn: {{ requiredEnv "FORMBRICKS_ROLE_ARN" }}
|
||||
additionalLabels: {}
|
||||
enabled: true
|
||||
name: formbricks-stage
|
||||
|
||||
## Dependencies
|
||||
postgresql:
|
||||
enabled: false
|
||||
redis:
|
||||
enabled: false
|
||||
@@ -1,92 +0,0 @@
|
||||
## Deployment & Autoscaling
|
||||
deployment:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 1
|
||||
memory: 1Gi
|
||||
env: {}
|
||||
envFrom:
|
||||
app-env:
|
||||
nameSuffix: app-env
|
||||
type: secret
|
||||
nodeSelector:
|
||||
karpenter.sh/capacity-type: on-demand
|
||||
reloadOnChange: true
|
||||
autoscaling:
|
||||
enabled: true
|
||||
maxReplicas: 95
|
||||
minReplicas: 3
|
||||
metrics:
|
||||
- resource:
|
||||
name: cpu
|
||||
target:
|
||||
averageUtilization: 60
|
||||
type: Utilization
|
||||
type: Resource
|
||||
- resource:
|
||||
name: memory
|
||||
target:
|
||||
averageUtilization: 60
|
||||
type: Utilization
|
||||
type: Resource
|
||||
|
||||
### Secrets
|
||||
secret:
|
||||
enabled: false
|
||||
externalSecret:
|
||||
enabled: true
|
||||
files:
|
||||
app-env:
|
||||
dataFrom:
|
||||
key: prod/formbricks/environment
|
||||
app-secrets:
|
||||
dataFrom:
|
||||
key: prod/formbricks/secrets
|
||||
refreshInterval: 1m
|
||||
secretStore:
|
||||
kind: ClusterSecretStore
|
||||
name: aws-secrets-manager
|
||||
|
||||
## Ingress
|
||||
ingress:
|
||||
annotations:
|
||||
alb.ingress.kubernetes.io/certificate-arn: {{ requiredEnv "FORMBRICKS_INGRESS_CERT_ARN" }}
|
||||
alb.ingress.kubernetes.io/group.name: formbricks
|
||||
alb.ingress.kubernetes.io/healthcheck-path: /health
|
||||
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
|
||||
alb.ingress.kubernetes.io/scheme: internet-facing
|
||||
alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-2021-06
|
||||
alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=600,client_keep_alive.seconds=590
|
||||
alb.ingress.kubernetes.io/ssl-redirect: "443"
|
||||
alb.ingress.kubernetes.io/target-type: ip
|
||||
enabled: true
|
||||
hosts:
|
||||
- host: app.k8s.formbricks.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
serviceName: formbricks
|
||||
- host: app.formbricks.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
serviceName: formbricks
|
||||
ingressClassName: alb
|
||||
|
||||
## RBAC
|
||||
rbac:
|
||||
enabled: true
|
||||
serviceAccount:
|
||||
annotations:
|
||||
eks.amazonaws.com/role-arn: {{ requiredEnv "FORMBRICKS_ROLE_ARN" }}
|
||||
additionalLabels: {}
|
||||
enabled: true
|
||||
name: formbricks
|
||||
|
||||
## Dependencies
|
||||
postgresql:
|
||||
enabled: false
|
||||
redis:
|
||||
enabled: false
|
||||
Generated
-205
@@ -1,205 +0,0 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "5.100.0"
|
||||
constraints = ">= 3.29.0, >= 4.0.0, >= 4.8.0, >= 4.33.0, >= 4.36.0, >= 4.47.0, >= 4.63.0, >= 5.0.0, >= 5.46.0, >= 5.73.0, >= 5.79.0, >= 5.81.0, >= 5.83.0, >= 5.86.0, >= 5.95.0, < 6.0.0"
|
||||
hashes = [
|
||||
"h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=",
|
||||
"zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
|
||||
"zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
|
||||
"zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
|
||||
"zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
|
||||
"zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
|
||||
"zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
|
||||
"zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
|
||||
"zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
|
||||
"zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
|
||||
"zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
|
||||
"zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
|
||||
"zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
|
||||
"zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/cloudinit" {
|
||||
version = "2.3.7"
|
||||
constraints = ">= 2.0.0"
|
||||
hashes = [
|
||||
"h1:M9TpQxKAE/hyOwytdX9MUNZw30HoD/OXqYIug5fkqH8=",
|
||||
"zh:06f1c54e919425c3139f8aeb8fcf9bceca7e560d48c9f0c1e3bb0a8ad9d9da1e",
|
||||
"zh:0e1e4cf6fd98b019e764c28586a386dc136129fef50af8c7165a067e7e4a31d5",
|
||||
"zh:1871f4337c7c57287d4d67396f633d224b8938708b772abfc664d1f80bd67edd",
|
||||
"zh:2b9269d91b742a71b2248439d5e9824f0447e6d261bfb86a8a88528609b136d1",
|
||||
"zh:3d8ae039af21426072c66d6a59a467d51f2d9189b8198616888c1b7fc42addc7",
|
||||
"zh:3ef4e2db5bcf3e2d915921adced43929214e0946a6fb11793085d9a48995ae01",
|
||||
"zh:42ae54381147437c83cbb8790cc68935d71b6357728a154109d3220b1beb4dc9",
|
||||
"zh:4496b362605ae4cbc9ef7995d102351e2fe311897586ffc7a4a262ccca0c782a",
|
||||
"zh:652a2401257a12706d32842f66dac05a735693abcb3e6517d6b5e2573729ba13",
|
||||
"zh:7406c30806f5979eaed5f50c548eced2ea18ea121e01801d2f0d4d87a04f6a14",
|
||||
"zh:7848429fd5a5bcf35f6fee8487df0fb64b09ec071330f3ff240c0343fe2a5224",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/external" {
|
||||
version = "2.3.5"
|
||||
constraints = ">= 1.0.0"
|
||||
hashes = [
|
||||
"h1:FnUk98MI5nOh3VJ16cHf8mchQLewLfN1qZG/MqNgPrI=",
|
||||
"zh:6e89509d056091266532fa64de8c06950010498adf9070bf6ff85bc485a82562",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:86868aec05b58dc0aa1904646a2c26b9367d69b890c9ad70c33c0d3aa7b1485a",
|
||||
"zh:a2ce38fda83a62fa5fb5a70e6ca8453b168575feb3459fa39803f6f40bd42154",
|
||||
"zh:a6c72798f4a9a36d1d1433c0372006cc9b904e8cfd60a2ae03ac5b7d2abd2398",
|
||||
"zh:a8a3141d2fc71c86bf7f3c13b0b3be8a1b0f0144a47572a15af4dfafc051e28a",
|
||||
"zh:aa20a1242eb97445ad26ebcfb9babf2cd675bdb81cac5f989268ebefa4ef278c",
|
||||
"zh:b58a22445fb8804e933dcf835ab06c29a0f33148dce61316814783ee7f4e4332",
|
||||
"zh:cb5626a661ee761e0576defb2a2d75230a3244799d380864f3089c66e99d0dcc",
|
||||
"zh:d1acb00d20445f682c4e705c965e5220530209c95609194c2dc39324f3d4fcce",
|
||||
"zh:d91a254ba77b69a29d8eae8ed0e9367cbf0ea6ac1a85b58e190f8cb096a40871",
|
||||
"zh:f6592327673c9f85cdb6f20336faef240abae7621b834f189c4a62276ea5db41",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/helm" {
|
||||
version = "2.17.0"
|
||||
constraints = ">= 2.9.0, ~> 2.17, < 3.0.0"
|
||||
hashes = [
|
||||
"h1:kQMkcPVvHOguOqnxoEU2sm1ND9vCHiT8TvZ2x6v/Rsw=",
|
||||
"zh:06fb4e9932f0afc1904d2279e6e99353c2ddac0d765305ce90519af410706bd4",
|
||||
"zh:104eccfc781fc868da3c7fec4385ad14ed183eb985c96331a1a937ac79c2d1a7",
|
||||
"zh:129345c82359837bb3f0070ce4891ec232697052f7d5ccf61d43d818912cf5f3",
|
||||
"zh:3956187ec239f4045975b35e8c30741f701aa494c386aaa04ebabffe7749f81c",
|
||||
"zh:66a9686d92a6b3ec43de3ca3fde60ef3d89fb76259ed3313ca4eb9bb8c13b7dd",
|
||||
"zh:88644260090aa621e7e8083585c468c8dd5e09a3c01a432fb05da5c4623af940",
|
||||
"zh:a248f650d174a883b32c5b94f9e725f4057e623b00f171936dcdcc840fad0b3e",
|
||||
"zh:aa498c1f1ab93be5c8fbf6d48af51dc6ef0f10b2ea88d67bcb9f02d1d80d3930",
|
||||
"zh:bf01e0f2ec2468c53596e027d376532a2d30feb72b0b5b810334d043109ae32f",
|
||||
"zh:c46fa84cc8388e5ca87eb575a534ebcf68819c5a5724142998b487cb11246654",
|
||||
"zh:d0c0f15ffc115c0965cbfe5c81f18c2e114113e7a1e6829f6bfd879ce5744fbb",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/kubernetes" {
|
||||
version = "2.38.0"
|
||||
constraints = ">= 2.20.0, ~> 2.36"
|
||||
hashes = [
|
||||
"h1:soK8Lt0SZ6dB+HsypFRDzuX/npqlMU6M0fvyaR1yW0k=",
|
||||
"zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0",
|
||||
"zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f",
|
||||
"zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b",
|
||||
"zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12",
|
||||
"zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2",
|
||||
"zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc",
|
||||
"zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15",
|
||||
"zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396",
|
||||
"zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d",
|
||||
"zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
"zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/local" {
|
||||
version = "2.5.3"
|
||||
constraints = ">= 1.0.0"
|
||||
hashes = [
|
||||
"h1:MCzg+hs1/ZQ32u56VzJMWP9ONRQPAAqAjuHuzbyshvI=",
|
||||
"zh:284d4b5b572eacd456e605e94372f740f6de27b71b4e1fd49b63745d8ecd4927",
|
||||
"zh:40d9dfc9c549e406b5aab73c023aa485633c1b6b730c933d7bcc2fa67fd1ae6e",
|
||||
"zh:6243509bb208656eb9dc17d3c525c89acdd27f08def427a0dce22d5db90a4c8b",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:885d85869f927853b6fe330e235cd03c337ac3b933b0d9ae827ec32fa1fdcdbf",
|
||||
"zh:bab66af51039bdfcccf85b25fe562cbba2f54f6b3812202f4873ade834ec201d",
|
||||
"zh:c505ff1bf9442a889ac7dca3ac05a8ee6f852e0118dd9a61796a2f6ff4837f09",
|
||||
"zh:d36c0b5770841ddb6eaf0499ba3de48e5d4fc99f4829b6ab66b0fab59b1aaf4f",
|
||||
"zh:ddb6a407c7f3ec63efb4dad5f948b54f7f4434ee1a2607a49680d494b1776fe1",
|
||||
"zh:e0dafdd4500bec23d3ff221e3a9b60621c5273e5df867bc59ef6b7e41f5c91f6",
|
||||
"zh:ece8742fd2882a8fc9d6efd20e2590010d43db386b920b2a9c220cfecc18de47",
|
||||
"zh:f4c6b3eb8f39105004cf720e202f04f57e3578441cfb76ca27611139bc116a82",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/null" {
|
||||
version = "3.2.4"
|
||||
constraints = ">= 2.0.0, >= 3.0.0"
|
||||
hashes = [
|
||||
"h1:L5V05xwp/Gto1leRryuesxjMfgZwjb7oool4WS1UEFQ=",
|
||||
"zh:59f6b52ab4ff35739647f9509ee6d93d7c032985d9f8c6237d1f8a59471bbbe2",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:795c897119ff082133150121d39ff26cb5f89a730a2c8c26f3a9c1abf81a9c43",
|
||||
"zh:7b9c7b16f118fbc2b05a983817b8ce2f86df125857966ad356353baf4bff5c0a",
|
||||
"zh:85e33ab43e0e1726e5f97a874b8e24820b6565ff8076523cc2922ba671492991",
|
||||
"zh:9d32ac3619cfc93eb3c4f423492a8e0f79db05fec58e449dee9b2d5873d5f69f",
|
||||
"zh:9e15c3c9dd8e0d1e3731841d44c34571b6c97f5b95e8296a45318b94e5287a6e",
|
||||
"zh:b4c2ab35d1b7696c30b64bf2c0f3a62329107bd1a9121ce70683dec58af19615",
|
||||
"zh:c43723e8cc65bcdf5e0c92581dcbbdcbdcf18b8d2037406a5f2033b1e22de442",
|
||||
"zh:ceb5495d9c31bfb299d246ab333f08c7fb0d67a4f82681fbf47f2a21c3e11ab5",
|
||||
"zh:e171026b3659305c558d9804062762d168f50ba02b88b231d20ec99578a6233f",
|
||||
"zh:ed0fe2acdb61330b01841fa790be00ec6beaac91d41f311fb8254f74eb6a711f",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/random" {
|
||||
version = "3.7.2"
|
||||
constraints = ">= 2.0.0, >= 3.6.0"
|
||||
hashes = [
|
||||
"h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=",
|
||||
"zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
|
||||
"zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
|
||||
"zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab",
|
||||
"zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3",
|
||||
"zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212",
|
||||
"zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34",
|
||||
"zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967",
|
||||
"zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d",
|
||||
"zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62",
|
||||
"zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/time" {
|
||||
version = "0.13.1"
|
||||
constraints = ">= 0.9.0"
|
||||
hashes = [
|
||||
"h1:ZT5ppCNIModqk3iOkVt5my8b8yBHmDpl663JtXAIRqM=",
|
||||
"zh:02cb9aab1002f0f2a94a4f85acec8893297dc75915f7404c165983f720a54b74",
|
||||
"zh:04429b2b31a492d19e5ecf999b116d396dac0b24bba0d0fb19ecaefe193fdb8f",
|
||||
"zh:26f8e51bb7c275c404ba6028c1b530312066009194db721a8427a7bc5cdbc83a",
|
||||
"zh:772ff8dbdbef968651ab3ae76d04afd355c32f8a868d03244db3f8496e462690",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:898db5d2b6bd6ca5457dccb52eedbc7c5b1a71e4a4658381bcbb38cedbbda328",
|
||||
"zh:8de913bf09a3fa7bedc29fec18c47c571d0c7a3d0644322c46f3aa648cf30cd8",
|
||||
"zh:9402102c86a87bdfe7e501ffbb9c685c32bbcefcfcf897fd7d53df414c36877b",
|
||||
"zh:b18b9bb1726bb8cfbefc0a29cf3657c82578001f514bcf4c079839b6776c47f0",
|
||||
"zh:b9d31fdc4faecb909d7c5ce41d2479dd0536862a963df434be4b16e8e4edc94d",
|
||||
"zh:c951e9f39cca3446c060bd63933ebb89cedde9523904813973fbc3d11863ba75",
|
||||
"zh:e5b773c0d07e962291be0e9b413c7a22c044b8c7b58c76e8aa91d1659990dfb5",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/tls" {
|
||||
version = "4.1.0"
|
||||
constraints = ">= 3.0.0"
|
||||
hashes = [
|
||||
"h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=",
|
||||
"zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2",
|
||||
"zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8",
|
||||
"zh:35808142ef850c0c60dd93dc06b95c747720ed2c40c89031781165f0c2baa2fc",
|
||||
"zh:35b5dc95bc75f0b3b9c5ce54d4d7600c1ebc96fbb8dfca174536e8bf103c8cdc",
|
||||
"zh:38aa27c6a6c98f1712aa5cc30011884dc4b128b4073a4a27883374bfa3ec9fac",
|
||||
"zh:51fb247e3a2e88f0047cb97bb9df7c228254a3b3021c5534e4563b4007e6f882",
|
||||
"zh:62b981ce491e38d892ba6364d1d0cdaadcee37cc218590e07b310b1dfa34be2d",
|
||||
"zh:bc8e47efc611924a79f947ce072a9ad698f311d4a60d0b4dfff6758c912b7298",
|
||||
"zh:c149508bd131765d1bc085c75a870abb314ff5a6d7f5ac1035a8892d686b6297",
|
||||
"zh:d38d40783503d278b63858978d40e07ac48123a2925e1a6b47e62179c046f87a",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
"zh:fb07f708e3316615f6d218cec198504984c0ce7000b9f1eebff7516e384f4b54",
|
||||
]
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
# ################################################################################
|
||||
# # GitOps Bridge: Bootstrap
|
||||
# ################################################################################
|
||||
# locals {
|
||||
# addons = {
|
||||
# enable_cert_manager = true
|
||||
# enable_external_dns = true
|
||||
# enable_istio = false
|
||||
# enable_istio_ingress = false
|
||||
# enable_external_secrets = true
|
||||
# enable_metrics_server = false
|
||||
# enable_keda = false
|
||||
# enable_aws_load_balancer_controller = true
|
||||
# enable_aws_ebs_csi_resources = false
|
||||
# enable_velero = false
|
||||
# enable_observability = false
|
||||
# enable_karpenter = true
|
||||
# }
|
||||
#
|
||||
# addons_default_versions = {
|
||||
# cert_manager = "v1.17.1"
|
||||
# external_dns = "1.15.2"
|
||||
# karpenter = "1.3.0"
|
||||
# external_secrets = "0.14.3"
|
||||
# aws_load_balancer_controller = "1.10.0"
|
||||
# # keda = "2.16.0"
|
||||
# # istio = "1.23.3"
|
||||
# }
|
||||
#
|
||||
# addons_metadata = merge(
|
||||
# # module.addons.gitops_metadata
|
||||
# {
|
||||
# aws_cluster_name = module.eks.cluster_name
|
||||
# aws_region = data.aws_region.selected.name
|
||||
# aws_account_id = data.aws_caller_identity.current.account_id
|
||||
# aws_vpc_id = module.vpc.vpc_id
|
||||
# }
|
||||
# )
|
||||
#
|
||||
# argocd_apps = {
|
||||
# eks-addons = {
|
||||
# project = "default"
|
||||
# repo_url = var.addons_repo_url
|
||||
# target_revision = var.addons_target_revision
|
||||
# addons_repo_revision = var.addons_target_revision
|
||||
# path = var.addons_repo_path
|
||||
# values = merge({
|
||||
# addons_repo_revision = var.addons_target_revision
|
||||
# certManager = {
|
||||
# enabled = local.addons.enable_cert_manager
|
||||
# iamRoleArn = try(module.addons.gitops_metadata.cert_manager_iam_role_arn, "")
|
||||
# values = try(yamldecode(join("\n", var.cert_manager_helm_config.values)), {})
|
||||
# chartVersion = try(var.cert_manager_helm_config.chart_version, local.addons_default_versions.cert_manager)
|
||||
# }
|
||||
# externalDNS = {
|
||||
# enabled = local.addons.enable_external_dns
|
||||
# iamRoleArn = try(module.addons.gitops_metadata.external_dns_iam_role_arn, "")
|
||||
# values = try(yamldecode(join("\n", var.external_dns_helm_config.values)), {})
|
||||
# chartVersion = try(var.external_dns_helm_config.chart_version, local.addons_default_versions.external_dns)
|
||||
# }
|
||||
# externalSecrets = {
|
||||
# enabled = local.addons.enable_external_secrets
|
||||
# iamRoleArn = try(module.addons.gitops_metadata.external_secrets_iam_role_arn, "")
|
||||
# values = try(yamldecode(join("\n", var.external_secrets_helm_config.values)), {})
|
||||
# chartVersion = try(var.external_secrets_helm_config.chart_version, local.addons_default_versions.external_secrets)
|
||||
# }
|
||||
# karpenter = {
|
||||
# enabled = true
|
||||
# iamRoleArn = try(module.addons.gitops_metadata.karpenter_iam_role_arn, "")
|
||||
# values = try(yamldecode(join("\n", var.karpenter_helm_config.values)), {})
|
||||
# chartVersion = try(var.karpenter_helm_config.chart_version, local.addons_default_versions.karpenter)
|
||||
# enableCrdWebhookConfig = true
|
||||
# clusterName = module.eks.cluster_name
|
||||
# clusterEndpoint = module.eks.cluster_endpoint
|
||||
# interruptionQueue = try(module.addons.gitops_metadata.karpenter_interruption_queue, null)
|
||||
# nodeIamRoleName = try(module.addons.gitops_metadata.karpenter_node_iam_role_arn, null)
|
||||
# }
|
||||
# loadBalancerController = {
|
||||
# enabled = local.addons.enable_aws_load_balancer_controller
|
||||
# iamRoleArn = try(module.addons.gitops_metadata.aws_load_balancer_controller_iam_role_arn, "")
|
||||
# values = try(yamldecode(join("\n", var.aws_load_balancer_controller_helm_config.values)), {})
|
||||
# clusterName = module.eks.cluster_name
|
||||
# chartVersion = try(var.aws_load_balancer_controller_helm_config.chart_version, local.addons_default_versions.aws_load_balancer_controller)
|
||||
# vpcId = module.vpc.vpc_id
|
||||
# }
|
||||
# })
|
||||
# }
|
||||
# workloads = {
|
||||
# project = "default"
|
||||
# repo_url = var.workloads_repo_url
|
||||
# target_revision = var.workloads_target_revision
|
||||
# addons_repo_revision = var.workloads_target_revision
|
||||
# path = var.workloads_repo_path
|
||||
# values = merge({
|
||||
# addons_repo_revision = var.workloads_target_revision
|
||||
# formbricks = {
|
||||
# certificateArn = try(module.acm.acm_certificate_arn, "")
|
||||
# ingressHost = "app.k8s.formbricks.com"
|
||||
# env = {
|
||||
# TEST = {
|
||||
# value = "test "
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# })
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# variable "enable_gitops_bridge_bootstrap" {
|
||||
# default = true
|
||||
# }
|
||||
#
|
||||
# module "gitops_bridge_bootstrap" {
|
||||
# count = var.enable_gitops_bridge_bootstrap ? 1 : 0
|
||||
# source = "../modules/argocd-gitops-bridge"
|
||||
#
|
||||
# cluster = {
|
||||
# metadata = local.addons_metadata
|
||||
# }
|
||||
# argocd = {
|
||||
# chart_version = "7.8.7"
|
||||
# values = [
|
||||
# <<-EOT
|
||||
# global:
|
||||
# nodeSelector:
|
||||
# CriticalAddonsOnly: "true"
|
||||
# tolerations:
|
||||
# - key: "CriticalAddonsOnly"
|
||||
# operator: "Exists"
|
||||
# effect: "NoSchedule"
|
||||
# configs:
|
||||
# params:
|
||||
# server.insecure: true
|
||||
# EOT
|
||||
# ]
|
||||
# }
|
||||
# apps = local.argocd_apps
|
||||
# }
|
||||
#
|
||||
# ###############################################################################
|
||||
# # EKS Blueprints Addons
|
||||
# ###############################################################################
|
||||
# module "addons" {
|
||||
# source = "../modules/addons"
|
||||
# oidc_provider_arn = module.eks.oidc_provider_arn
|
||||
# aws_region = data.aws_region.selected.name
|
||||
# aws_account_id = data.aws_caller_identity.current.account_id
|
||||
# aws_partition = data.aws_partition.current.partition
|
||||
# cluster_name = module.eks.cluster_name
|
||||
# cluster_endpoint = module.eks.cluster_endpoint
|
||||
# cluster_certificate_authority_data = module.eks.cluster_certificate_authority_data
|
||||
# cluster_token = data.aws_eks_cluster_auth.eks.token
|
||||
# cluster_version = module.eks.cluster_version
|
||||
# vpc_id = module.vpc.vpc_id
|
||||
# node_security_group_id = module.eks.node_security_group_id
|
||||
# cluster_security_group_id = module.eks.cluster_security_group_id
|
||||
#
|
||||
# # Using GitOps Bridge
|
||||
# create_kubernetes_resources = var.enable_gitops_bridge_bootstrap ? false : true
|
||||
#
|
||||
# # Cert Manager
|
||||
# enable_cert_manager = local.addons.enable_cert_manager
|
||||
#
|
||||
# # External DNS
|
||||
# enable_external_dns = local.addons.enable_external_dns
|
||||
#
|
||||
# # Karpenter
|
||||
# enable_karpenter = local.addons.enable_karpenter
|
||||
#
|
||||
# # External Secrets
|
||||
# enable_external_secrets = local.addons.enable_external_secrets
|
||||
#
|
||||
# # Load Balancer Controller
|
||||
# enable_aws_load_balancer_controller = local.addons.enable_aws_load_balancer_controller
|
||||
#
|
||||
# }
|
||||
@@ -1,252 +0,0 @@
|
||||
data "aws_ssm_parameter" "slack_notification_channel" {
|
||||
name = "/prod/formbricks/slack-webhook-url"
|
||||
with_decryption = true
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_log_group" "cloudwatch_cis_benchmark" {
|
||||
name = "/aws/cis-benchmark-group"
|
||||
retention_in_days = 365
|
||||
}
|
||||
|
||||
module "notify-slack" {
|
||||
source = "terraform-aws-modules/notify-slack/aws"
|
||||
version = "6.6.0"
|
||||
|
||||
slack_channel = "kubernetes"
|
||||
slack_username = "formbricks-cloudwatch"
|
||||
slack_webhook_url = data.aws_ssm_parameter.slack_notification_channel.value
|
||||
sns_topic_name = "cloudwatch-alarms"
|
||||
create_sns_topic = true
|
||||
}
|
||||
|
||||
module "cloudwatch_cis-alarms" {
|
||||
source = "terraform-aws-modules/cloudwatch/aws//modules/cis-alarms"
|
||||
version = "5.7.1"
|
||||
log_group_name = aws_cloudwatch_log_group.cloudwatch_cis_benchmark.name
|
||||
alarm_actions = [module.notify-slack.slack_topic_arn]
|
||||
}
|
||||
|
||||
locals {
|
||||
alb_id = "app/k8s-formbricks-21ab9ecd60/342ed65d128ce4cb"
|
||||
alarms = {
|
||||
ALB_HTTPCode_Target_5XX_Count = {
|
||||
alarm_description = "Average API 5XX target group error code count is too high"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 5
|
||||
period = 600
|
||||
unit = "Count"
|
||||
namespace = "AWS/ApplicationELB"
|
||||
metric_name = "HTTPCode_Target_5XX_Count"
|
||||
statistic = "Sum"
|
||||
dimensions = {
|
||||
LoadBalancer = local.alb_id
|
||||
}
|
||||
}
|
||||
ALB_HTTPCode_ELB_5XX_Count = {
|
||||
alarm_description = "Average API 5XX load balancer error code count is too high"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 10
|
||||
period = 600
|
||||
unit = "Count"
|
||||
namespace = "AWS/ApplicationELB"
|
||||
metric_name = "HTTPCode_ELB_5XX_Count"
|
||||
statistic = "Sum"
|
||||
dimensions = {
|
||||
LoadBalancer = local.alb_id
|
||||
}
|
||||
}
|
||||
ALB_TargetResponseTime = {
|
||||
alarm_description = format("Average API response time is greater than %s", 5)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 5
|
||||
period = 60
|
||||
unit = "Seconds"
|
||||
namespace = "AWS/ApplicationELB"
|
||||
metric_name = "TargetResponseTime"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
LoadBalancer = local.alb_id
|
||||
}
|
||||
}
|
||||
ALB_UnHealthyHostCount = {
|
||||
alarm_description = format("Unhealthy host count is greater than %s", 2)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 2
|
||||
period = 60
|
||||
unit = "Count"
|
||||
namespace = "AWS/ApplicationELB"
|
||||
metric_name = "UnHealthyHostCount"
|
||||
statistic = "Minimum"
|
||||
dimensions = {
|
||||
LoadBalancer = local.alb_id
|
||||
}
|
||||
}
|
||||
RDS_CPUUtilization = {
|
||||
alarm_description = format("Average RDS CPU utilization is greater than %s", 80)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 80
|
||||
period = 60
|
||||
unit = "Percent"
|
||||
namespace = "AWS/RDS"
|
||||
metric_name = "CPUUtilization"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
|
||||
}
|
||||
}
|
||||
RDS_FreeStorageSpace = {
|
||||
alarm_description = format("Average RDS free storage space is less than %s", 5)
|
||||
comparison_operator = "LessThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 5
|
||||
period = 60
|
||||
unit = "Gigabytes"
|
||||
namespace = "AWS/RDS"
|
||||
metric_name = "FreeStorageSpace"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
|
||||
}
|
||||
}
|
||||
RDS_FreeableMemory = {
|
||||
alarm_description = format("Average RDS freeable memory is less than %s", 100)
|
||||
comparison_operator = "LessThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 100
|
||||
period = 60
|
||||
unit = "Megabytes"
|
||||
namespace = "AWS/RDS"
|
||||
metric_name = "FreeableMemory"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
|
||||
}
|
||||
}
|
||||
RDS_DiskQueueDepth = {
|
||||
alarm_description = format("Average RDS disk queue depth is greater than %s", 1)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 1
|
||||
period = 60
|
||||
unit = "Count"
|
||||
namespace = "AWS/RDS"
|
||||
metric_name = "DiskQueueDepth"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
|
||||
}
|
||||
}
|
||||
RDS_ReadIOPS = {
|
||||
alarm_description = format("Average RDS read IOPS is greater than %s", 1000)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 1000
|
||||
period = 60
|
||||
unit = "Count/Second"
|
||||
namespace = "AWS/RDS"
|
||||
metric_name = "ReadIOPS"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
|
||||
}
|
||||
}
|
||||
RDS_WriteIOPS = {
|
||||
alarm_description = format("Average RDS write IOPS is greater than %s", 1000)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 1000
|
||||
period = 60
|
||||
unit = "Count/Second"
|
||||
namespace = "AWS/RDS"
|
||||
metric_name = "WriteIOPS"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
|
||||
}
|
||||
}
|
||||
SQS_ApproximateAgeOfOldestMessage = {
|
||||
alarm_description = format("Average SQS approximate age of oldest message is greater than %s", 300)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 300
|
||||
period = 60
|
||||
unit = "Seconds"
|
||||
namespace = "AWS/SQS"
|
||||
metric_name = "ApproximateAgeOfOldestMessage"
|
||||
statistic = "Maximum"
|
||||
dimensions = {
|
||||
QueueName = module.karpenter.queue_name
|
||||
}
|
||||
}
|
||||
DynamoDB_ConsumedReadCapacityUnits = {
|
||||
alarm_description = format("Average DynamoDB consumed read capacity units is greater than %s", 90)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 90
|
||||
period = 60
|
||||
unit = "Count"
|
||||
namespace = "AWS/DynamoDB"
|
||||
metric_name = "ConsumedReadCapacityUnits"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
TableName = "terraform-lock"
|
||||
}
|
||||
}
|
||||
DynamoDB_ConsumedWriteCapacityUnits = {
|
||||
alarm_description = format("Average DynamoDB consumed write capacity units is greater than %s", 90)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 90
|
||||
period = 60
|
||||
unit = "Count"
|
||||
namespace = "AWS/DynamoDB"
|
||||
metric_name = "ConsumedWriteCapacityUnits"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
TableName = "terraform-lock"
|
||||
}
|
||||
}
|
||||
Lambda_Errors = {
|
||||
alarm_description = format("Average Lambda errors is greater than %s", 1)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 1
|
||||
period = 60
|
||||
unit = "Count"
|
||||
namespace = "AWS/Lambda"
|
||||
metric_name = "Errors"
|
||||
statistic = "Sum"
|
||||
dimensions = {
|
||||
FunctionName = module.notify-slack.notify_slack_lambda_function_name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module "metric_alarm" {
|
||||
source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm"
|
||||
version = "5.7.1"
|
||||
|
||||
for_each = local.alarms
|
||||
alarm_name = each.key
|
||||
alarm_description = each.value.alarm_description
|
||||
comparison_operator = each.value.comparison_operator
|
||||
evaluation_periods = each.value.evaluation_periods
|
||||
threshold = each.value.threshold
|
||||
period = each.value.period
|
||||
unit = each.value.unit
|
||||
insufficient_data_actions = []
|
||||
|
||||
namespace = each.value.namespace
|
||||
metric_name = each.value.metric_name
|
||||
statistic = each.value.statistic
|
||||
|
||||
dimensions = each.value.dimensions
|
||||
|
||||
alarm_actions = [module.notify-slack.slack_topic_arn]
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
data "aws_region" "selected" {}
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_availability_zones" "available" {}
|
||||
data "aws_partition" "current" {}
|
||||
|
||||
data "aws_eks_cluster_auth" "eks" {
|
||||
name = module.eks.cluster_name
|
||||
}
|
||||
|
||||
data "aws_ecrpublic_authorization_token" "token" {
|
||||
provider = aws.virginia
|
||||
}
|
||||
|
||||
data "aws_iam_roles" "administrator" {
|
||||
name_regex = "AWSReservedSSO_AdministratorAccess"
|
||||
}
|
||||
|
||||
data "aws_iam_roles" "github" {
|
||||
name_regex = "formbricks-prod-github"
|
||||
}
|
||||
|
||||
data "aws_acm_certificate" "formbricks" {
|
||||
domain = local.domain
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
################################################################################
|
||||
# ElastiCache Module
|
||||
################################################################################
|
||||
locals {
|
||||
valkey_major_version = 8
|
||||
}
|
||||
|
||||
moved {
|
||||
from = random_password.valkey
|
||||
to = random_password.valkey["prod"]
|
||||
}
|
||||
|
||||
resource "random_password" "valkey" {
|
||||
for_each = local.envs
|
||||
length = 20
|
||||
special = false
|
||||
}
|
||||
|
||||
module "valkey_sg" {
|
||||
source = "terraform-aws-modules/security-group/aws"
|
||||
version = "~> 5.0"
|
||||
|
||||
name = "valkey-sg"
|
||||
description = "Security group for VPC traffic"
|
||||
vpc_id = module.vpc.vpc_id
|
||||
|
||||
ingress_cidr_blocks = [module.vpc.vpc_cidr_block]
|
||||
ingress_rules = ["redis-tcp"]
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
module "elasticache_user_group" {
|
||||
for_each = local.envs
|
||||
source = "terraform-aws-modules/elasticache/aws//modules/user-group"
|
||||
version = "1.4.1"
|
||||
|
||||
user_group_id = "${each.value}-valkey"
|
||||
create_default_user = false
|
||||
default_user = {
|
||||
user_id = each.value
|
||||
passwords = [random_password.valkey[each.key].result]
|
||||
}
|
||||
users = {
|
||||
"${each.value}" = {
|
||||
access_string = "on ~* +@all"
|
||||
passwords = [random_password.valkey[each.key].result]
|
||||
}
|
||||
}
|
||||
engine = "redis"
|
||||
tags = merge(local.tags, {
|
||||
terraform-aws-modules = "elasticache"
|
||||
})
|
||||
}
|
||||
|
||||
module "valkey_serverless" {
|
||||
for_each = local.envs
|
||||
source = "terraform-aws-modules/elasticache/aws//modules/serverless-cache"
|
||||
version = "1.4.1"
|
||||
|
||||
engine = "valkey"
|
||||
cache_name = "${each.value}-valkey-serverless"
|
||||
major_engine_version = local.valkey_major_version
|
||||
# cache_usage_limits = {
|
||||
# data_storage = {
|
||||
# maximum = 2
|
||||
# }
|
||||
# ecpu_per_second = {
|
||||
# maximum = 1000
|
||||
# }
|
||||
# }
|
||||
subnet_ids = module.vpc.database_subnets
|
||||
|
||||
security_group_ids = [
|
||||
module.valkey_sg.security_group_id
|
||||
]
|
||||
user_group_id = module.elasticache_user_group[each.key].group_id
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
################################################################################
|
||||
# GitHub OIDC Provider
|
||||
# Note: This is one per AWS account
|
||||
################################################################################
|
||||
module "iam_github_oidc_provider" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-github-oidc-provider"
|
||||
version = "5.54.0"
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# GitHub OIDC Role
|
||||
################################################################################
|
||||
|
||||
module "iam_github_oidc_role" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-github-oidc-role"
|
||||
version = "5.54.0"
|
||||
|
||||
name = "${local.name}-github"
|
||||
|
||||
subjects = [
|
||||
"repo:formbricks/*:*",
|
||||
]
|
||||
policies = {
|
||||
Administrator = "arn:aws:iam::aws:policy/AdministratorAccess"
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
@@ -1,533 +0,0 @@
|
||||
locals {
|
||||
project = "formbricks"
|
||||
environment = "prod"
|
||||
name = "${local.project}-${local.environment}"
|
||||
envs = {
|
||||
prod = "${local.project}-prod"
|
||||
stage = "${local.project}-stage"
|
||||
}
|
||||
vpc_cidr = "10.0.0.0/16"
|
||||
azs = slice(data.aws_availability_zones.available.names, 0, 3)
|
||||
tags = {
|
||||
Project = local.project
|
||||
Environment = local.environment
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = local.name
|
||||
}
|
||||
tags_map = {
|
||||
prod = {
|
||||
Project = local.project
|
||||
Environment = "prod"
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = "${local.project}-prod"
|
||||
}
|
||||
stage = {
|
||||
Project = local.project
|
||||
Environment = "stage"
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = "${local.project}-stage"
|
||||
}
|
||||
}
|
||||
domain = "k8s.formbricks.com"
|
||||
karpetner_helm_version = "1.3.1"
|
||||
karpenter_namespace = "karpenter"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Route53 Hosted Zone
|
||||
################################################################################
|
||||
module "route53_zones" {
|
||||
source = "terraform-aws-modules/route53/aws//modules/zones"
|
||||
version = "4.1.0"
|
||||
|
||||
zones = {
|
||||
"k8s.formbricks.com" = {
|
||||
comment = "${local.domain} (testing)"
|
||||
tags = {
|
||||
Name = local.domain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# VPC
|
||||
################################################################################
|
||||
module "vpc" {
|
||||
source = "terraform-aws-modules/vpc/aws"
|
||||
version = "5.19.0"
|
||||
|
||||
name = "${local.name}-vpc"
|
||||
cidr = local.vpc_cidr
|
||||
|
||||
azs = local.azs
|
||||
private_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 4, k)] # /20
|
||||
public_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 48)] # Public LB /24
|
||||
intra_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 52)] # eks interface /24
|
||||
database_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 56)] # RDS / Elastic cache /24
|
||||
database_subnet_group_name = "${local.name}-subnet-group"
|
||||
|
||||
enable_nat_gateway = true
|
||||
single_nat_gateway = true
|
||||
|
||||
public_subnet_tags = {
|
||||
"kubernetes.io/role/elb" = 1
|
||||
}
|
||||
|
||||
private_subnet_tags = {
|
||||
"kubernetes.io/role/internal-elb" = 1
|
||||
# Tags subnets for Karpenter auto-discovery
|
||||
"karpenter.sh/discovery" = "${local.name}-eks"
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# VPC Endpoints Module
|
||||
################################################################################
|
||||
module "vpc_vpc-endpoints" {
|
||||
source = "terraform-aws-modules/vpc/aws//modules/vpc-endpoints"
|
||||
version = "5.19.0"
|
||||
|
||||
vpc_id = module.vpc.vpc_id
|
||||
|
||||
endpoints = {
|
||||
"s3" = {
|
||||
service = "s3"
|
||||
service_type = "Gateway"
|
||||
route_table_ids = flatten([
|
||||
module.vpc.intra_route_table_ids,
|
||||
module.vpc.private_route_table_ids,
|
||||
module.vpc.public_route_table_ids
|
||||
])
|
||||
tags = { Name = "s3-vpc-endpoint" }
|
||||
}
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# EKS Module
|
||||
################################################################################
|
||||
module "ebs_csi_driver_irsa" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
|
||||
version = "~> 5.52"
|
||||
|
||||
role_name_prefix = "${local.name}-ebs-csi-driver-"
|
||||
|
||||
attach_ebs_csi_policy = true
|
||||
|
||||
oidc_providers = {
|
||||
main = {
|
||||
provider_arn = module.eks.oidc_provider_arn
|
||||
namespace_service_accounts = ["kube-system:ebs-csi-controller-sa"]
|
||||
}
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
module "eks" {
|
||||
source = "terraform-aws-modules/eks/aws"
|
||||
version = "20.37.2"
|
||||
|
||||
cluster_name = "${local.name}-eks"
|
||||
cluster_version = "1.32"
|
||||
|
||||
enable_cluster_creator_admin_permissions = false
|
||||
cluster_endpoint_public_access = false
|
||||
cloudwatch_log_group_retention_in_days = 365
|
||||
|
||||
cluster_addons = {
|
||||
coredns = {
|
||||
most_recent = true
|
||||
|
||||
}
|
||||
eks-pod-identity-agent = {
|
||||
most_recent = true
|
||||
}
|
||||
aws-ebs-csi-driver = {
|
||||
addon_version = "v1.46.0-eksbuild.1"
|
||||
service_account_role_arn = module.ebs_csi_driver_irsa.iam_role_arn
|
||||
}
|
||||
kube-proxy = {
|
||||
most_recent = true
|
||||
}
|
||||
vpc-cni = {
|
||||
most_recent = true
|
||||
}
|
||||
}
|
||||
|
||||
cluster_security_group_additional_rules = {
|
||||
ingress_from_vpc_cidr = {
|
||||
description = "Allow all traffic from the VPC CIDR"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
type = "ingress"
|
||||
cidr_blocks = [local.vpc_cidr]
|
||||
}
|
||||
}
|
||||
|
||||
kms_key_administrators = [
|
||||
tolist(data.aws_iam_roles.github.arns)[0],
|
||||
tolist(data.aws_iam_roles.administrator.arns)[0]
|
||||
]
|
||||
|
||||
kms_key_users = [
|
||||
tolist(data.aws_iam_roles.github.arns)[0],
|
||||
tolist(data.aws_iam_roles.administrator.arns)[0]
|
||||
]
|
||||
|
||||
access_entries = {
|
||||
administrator = {
|
||||
principal_arn = tolist(data.aws_iam_roles.administrator.arns)[0]
|
||||
policy_associations = {
|
||||
Admin = {
|
||||
policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
|
||||
access_scope = {
|
||||
type = "cluster"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
github = {
|
||||
principal_arn = tolist(data.aws_iam_roles.github.arns)[0]
|
||||
policy_associations = {
|
||||
Admin = {
|
||||
policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
|
||||
access_scope = {
|
||||
type = "cluster"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vpc_id = module.vpc.vpc_id
|
||||
subnet_ids = module.vpc.private_subnets
|
||||
control_plane_subnet_ids = module.vpc.intra_subnets
|
||||
|
||||
eks_managed_node_group_defaults = {
|
||||
iam_role_additional_policies = {
|
||||
AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
|
||||
}
|
||||
}
|
||||
|
||||
eks_managed_node_groups = {
|
||||
system = {
|
||||
ami_type = "BOTTLEROCKET_ARM_64"
|
||||
instance_types = ["t4g.small"]
|
||||
|
||||
min_size = 2
|
||||
max_size = 3
|
||||
desired_size = 2
|
||||
|
||||
labels = {
|
||||
CriticalAddonsOnly = "true"
|
||||
"karpenter.sh/controller" = "true"
|
||||
}
|
||||
|
||||
taints = {
|
||||
addons = {
|
||||
key = "CriticalAddonsOnly"
|
||||
value = "true"
|
||||
effect = "NO_SCHEDULE"
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node_security_group_tags = merge(local.tags, {
|
||||
# NOTE - if creating multiple security groups with this module, only tag the
|
||||
# security group that Karpenter should utilize with the following tag
|
||||
# (i.e. - at most, only one security group should have this tag in your account)
|
||||
"karpenter.sh/discovery" = "${local.name}-eks"
|
||||
})
|
||||
|
||||
tags = local.tags
|
||||
|
||||
}
|
||||
|
||||
module "karpenter" {
|
||||
source = "terraform-aws-modules/eks/aws//modules/karpenter"
|
||||
version = "20.34.0"
|
||||
|
||||
cluster_name = module.eks.cluster_name
|
||||
enable_v1_permissions = true
|
||||
|
||||
# Name needs to match role name passed to the EC2NodeClass
|
||||
node_iam_role_use_name_prefix = false
|
||||
node_iam_role_name = local.name
|
||||
create_pod_identity_association = true
|
||||
namespace = local.karpenter_namespace
|
||||
|
||||
# Used to attach additional IAM policies to the Karpenter node IAM role
|
||||
node_iam_role_additional_policies = {
|
||||
AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
output "karpenter_node_role" {
|
||||
value = module.karpenter.node_iam_role_name
|
||||
}
|
||||
|
||||
|
||||
|
||||
# resource "helm_release" "karpenter_crds" {
|
||||
# name = "karpenter-crds"
|
||||
# repository = "oci://public.ecr.aws/karpenter"
|
||||
# repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
# repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
# chart = "karpenter-crd"
|
||||
# version = "1.3.1"
|
||||
# namespace = local.karpenter_namespace
|
||||
# values = [
|
||||
# <<-EOT
|
||||
# webhook:
|
||||
# enabled: true
|
||||
# serviceNamespace: ${local.karpenter_namespace}
|
||||
# EOT
|
||||
# ]
|
||||
# }
|
||||
|
||||
# resource "helm_release" "karpenter" {
|
||||
# name = "karpenter"
|
||||
# repository = "oci://public.ecr.aws/karpenter"
|
||||
# repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
# repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
# chart = "karpenter"
|
||||
# version = "1.3.1"
|
||||
# namespace = local.karpenter_namespace
|
||||
# skip_crds = true
|
||||
#
|
||||
# values = [
|
||||
# <<-EOT
|
||||
# nodeSelector:
|
||||
# karpenter.sh/controller: 'true'
|
||||
# dnsPolicy: Default
|
||||
# settings:
|
||||
# clusterName: ${module.eks.cluster_name}
|
||||
# clusterEndpoint: ${module.eks.cluster_endpoint}
|
||||
# interruptionQueue: ${module.karpenter.queue_name}
|
||||
# EOT
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
# resource "kubernetes_manifest" "ec2_node_class" {
|
||||
# manifest = {
|
||||
# apiVersion = "karpenter.k8s.aws/v1"
|
||||
# kind = "EC2NodeClass"
|
||||
# metadata = {
|
||||
# name = "default"
|
||||
# }
|
||||
# spec = {
|
||||
# amiSelectorTerms = [
|
||||
# {
|
||||
# alias = "bottlerocket@latest"
|
||||
# }
|
||||
# ]
|
||||
# role = module.karpenter.node_iam_role_name
|
||||
# subnetSelectorTerms = [
|
||||
# {
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# securityGroupSelectorTerms = [
|
||||
# {
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
# resource "kubernetes_manifest" "node_pool" {
|
||||
# manifest = {
|
||||
# apiVersion = "karpenter.sh/v1"
|
||||
# kind = "NodePool"
|
||||
# metadata = {
|
||||
# name = "default"
|
||||
# }
|
||||
# spec = {
|
||||
# template = {
|
||||
# spec = {
|
||||
# nodeClassRef = {
|
||||
# group = "karpenter.k8s.aws"
|
||||
# kind = "EC2NodeClass"
|
||||
# name = "default"
|
||||
# }
|
||||
# requirements = [
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-family"
|
||||
# operator = "In"
|
||||
# values = ["c8g", "c7g", "m8g", "m7g", "r8g", "r7g"]
|
||||
# },
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-cpu"
|
||||
# operator = "In"
|
||||
# values = ["2", "4", "8"]
|
||||
# },
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-hypervisor"
|
||||
# operator = "In"
|
||||
# values = ["nitro"]
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
# limits = {
|
||||
# cpu = 1000
|
||||
# }
|
||||
# disruption = {
|
||||
# consolidationPolicy = "WhenEmptyOrUnderutilized"
|
||||
# consolidateAfter = "30s"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
module "eks_blueprints_addons" {
|
||||
source = "aws-ia/eks-blueprints-addons/aws"
|
||||
version = "~> 1"
|
||||
|
||||
cluster_name = module.eks.cluster_name
|
||||
cluster_endpoint = module.eks.cluster_endpoint
|
||||
cluster_version = module.eks.cluster_version
|
||||
oidc_provider_arn = module.eks.oidc_provider_arn
|
||||
|
||||
enable_metrics_server = true
|
||||
metrics_server = {
|
||||
chart_version = "3.12.2"
|
||||
}
|
||||
|
||||
enable_aws_load_balancer_controller = true
|
||||
aws_load_balancer_controller = {
|
||||
chart_version = "1.10.0"
|
||||
values = [
|
||||
<<-EOT
|
||||
vpcId: ${module.vpc.vpc_id}
|
||||
EOT
|
||||
]
|
||||
}
|
||||
enable_external_dns = true
|
||||
external_dns_route53_zone_arns = [module.route53_zones.route53_zone_zone_arn[local.domain]]
|
||||
external_dns = {
|
||||
chart_version = "1.15.2"
|
||||
}
|
||||
enable_cert_manager = false
|
||||
cert_manager = {
|
||||
chart_version = "v1.17.1"
|
||||
values = [
|
||||
<<-EOT
|
||||
installCRDs: false
|
||||
crds:
|
||||
enabled: true
|
||||
keep: true
|
||||
EOT
|
||||
]
|
||||
}
|
||||
|
||||
enable_external_secrets = true
|
||||
external_secrets = {
|
||||
chart_version = "0.14.3"
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
### Formbricks App
|
||||
|
||||
moved {
|
||||
from = module.formbricks_s3_bucket
|
||||
to = module.formbricks_s3_bucket["prod"]
|
||||
}
|
||||
|
||||
module "formbricks_s3_bucket" {
|
||||
for_each = local.envs
|
||||
source = "terraform-aws-modules/s3-bucket/aws"
|
||||
version = "4.6.0"
|
||||
|
||||
bucket = each.key == "prod" ? "formbricks-cloud-eks" : "formbricks-cloud-eks-${each.key}"
|
||||
force_destroy = true
|
||||
control_object_ownership = true
|
||||
object_ownership = "BucketOwnerPreferred"
|
||||
versioning = {
|
||||
enabled = true
|
||||
}
|
||||
cors_rule = [
|
||||
{
|
||||
allowed_methods = ["POST"]
|
||||
allowed_origins = ["https://*"]
|
||||
allowed_headers = ["*"]
|
||||
expose_headers = []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
moved {
|
||||
from = module.formbricks_app_iam_policy
|
||||
to = module.formbricks_app_iam_policy["prod"]
|
||||
}
|
||||
|
||||
module "formbricks_app_iam_policy" {
|
||||
for_each = local.envs
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-policy"
|
||||
version = "5.53.0"
|
||||
|
||||
name_prefix = each.key == "prod" ? "formbricks-" : "formbricks-${each.key}-"
|
||||
path = "/"
|
||||
description = "Policy for fombricks app"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"s3:*",
|
||||
]
|
||||
Resource = [
|
||||
module.formbricks_s3_bucket[each.key].s3_bucket_arn,
|
||||
"${module.formbricks_s3_bucket[each.key].s3_bucket_arn}/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
moved {
|
||||
from = module.formbricks_app_iam_role
|
||||
to = module.formbricks_app_iam_role["prod"]
|
||||
}
|
||||
|
||||
module "formbricks_app_iam_role" {
|
||||
for_each = local.envs
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
|
||||
version = "5.53.0"
|
||||
|
||||
role_name_prefix = each.key == "prod" ? "formbricks-" : "formbricks-${each.key}-"
|
||||
|
||||
role_policy_arns = {
|
||||
"formbricks" = module.formbricks_app_iam_policy[each.key].arn
|
||||
}
|
||||
assume_role_condition_test = "StringLike"
|
||||
|
||||
oidc_providers = {
|
||||
eks = {
|
||||
provider_arn = module.eks.oidc_provider_arn
|
||||
namespace_service_accounts = each.key == "prod" ? ["formbricks:*"] : ["formbricks-${each.key}:*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
module "loki_s3_bucket" {
|
||||
source = "terraform-aws-modules/s3-bucket/aws"
|
||||
version = "4.6.0"
|
||||
|
||||
bucket_prefix = "loki-"
|
||||
force_destroy = true
|
||||
control_object_ownership = true
|
||||
object_ownership = "BucketOwnerPreferred"
|
||||
}
|
||||
|
||||
module "observability_loki_iam_policy" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-policy"
|
||||
version = "5.53.0"
|
||||
|
||||
name_prefix = "loki-"
|
||||
path = "/"
|
||||
description = "Policy for fombricks observability apps"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"s3:*",
|
||||
]
|
||||
Resource = [
|
||||
module.loki_s3_bucket.s3_bucket_arn,
|
||||
"${module.loki_s3_bucket.s3_bucket_arn}/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
module "observability_loki_iam_role" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
|
||||
version = "5.53.0"
|
||||
|
||||
role_name_prefix = "loki-"
|
||||
|
||||
role_policy_arns = {
|
||||
"formbricks" = module.observability_loki_iam_policy.arn
|
||||
}
|
||||
assume_role_condition_test = "StringLike"
|
||||
|
||||
oidc_providers = {
|
||||
eks = {
|
||||
provider_arn = module.eks.oidc_provider_arn
|
||||
namespace_service_accounts = ["monitoring:loki"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module "observability_grafana_iam_policy" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-policy"
|
||||
version = "5.53.0"
|
||||
|
||||
name_prefix = "grafana-"
|
||||
path = "/"
|
||||
description = "Policy for Formbricks observability apps - Grafana"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowReadingMetricsFromCloudWatch"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"cloudwatch:DescribeAlarmsForMetric",
|
||||
"cloudwatch:DescribeAlarmHistory",
|
||||
"cloudwatch:DescribeAlarms",
|
||||
"cloudwatch:ListMetrics",
|
||||
"cloudwatch:GetMetricData",
|
||||
"cloudwatch:GetInsightRuleReport"
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "AllowReadingResourceMetricsFromPerformanceInsights"
|
||||
Effect = "Allow"
|
||||
Action = "pi:GetResourceMetrics"
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "AllowReadingLogsFromCloudWatch"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"logs:DescribeLogGroups",
|
||||
"logs:GetLogGroupFields",
|
||||
"logs:StartQuery",
|
||||
"logs:StopQuery",
|
||||
"logs:GetQueryResults",
|
||||
"logs:GetLogEvents"
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "AllowReadingTagsInstancesRegionsFromEC2"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ec2:DescribeTags",
|
||||
"ec2:DescribeInstances",
|
||||
"ec2:DescribeRegions"
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "AllowReadingResourcesForTags"
|
||||
Effect = "Allow"
|
||||
Action = "tag:GetResources"
|
||||
Resource = "*"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
module "observability_grafana_iam_role" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
|
||||
version = "5.53.0"
|
||||
|
||||
role_name_prefix = "grafana-"
|
||||
|
||||
role_policy_arns = {
|
||||
"formbricks" = module.observability_grafana_iam_policy.arn
|
||||
}
|
||||
assume_role_condition_test = "StringLike"
|
||||
|
||||
oidc_providers = {
|
||||
eks = {
|
||||
provider_arn = module.eks.oidc_provider_arn
|
||||
namespace_service_accounts = ["monitoring:grafana"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
provider "aws" {
|
||||
region = "eu-central-1"
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = "us-east-1"
|
||||
alias = "virginia"
|
||||
}
|
||||
|
||||
terraform {
|
||||
backend "s3" {
|
||||
bucket = "715841356175-terraform"
|
||||
key = "terraform.tfstate"
|
||||
region = "eu-central-1"
|
||||
dynamodb_table = "terraform-lock"
|
||||
}
|
||||
}
|
||||
|
||||
provider "kubernetes" {
|
||||
host = module.eks.cluster_endpoint
|
||||
cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
|
||||
token = data.aws_eks_cluster_auth.eks.token
|
||||
}
|
||||
|
||||
provider "helm" {
|
||||
kubernetes {
|
||||
host = module.eks.cluster_endpoint
|
||||
cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
|
||||
token = data.aws_eks_cluster_auth.eks.token
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
################################################################################
|
||||
# PostgreSQL Serverless v2
|
||||
################################################################################
|
||||
data "aws_rds_engine_version" "postgresql" {
|
||||
engine = "aurora-postgresql"
|
||||
version = "16.6"
|
||||
}
|
||||
|
||||
moved {
|
||||
from = random_password.postgres
|
||||
to = random_password.postgres["prod"]
|
||||
}
|
||||
|
||||
resource "random_password" "postgres" {
|
||||
for_each = local.envs
|
||||
length = 20
|
||||
special = false
|
||||
}
|
||||
|
||||
moved {
|
||||
from = module.rds-aurora
|
||||
to = module.rds-aurora["prod"]
|
||||
}
|
||||
|
||||
module "rds-aurora" {
|
||||
for_each = local.envs
|
||||
source = "terraform-aws-modules/rds-aurora/aws"
|
||||
version = "9.12.0"
|
||||
|
||||
name = "${each.value}-postgres"
|
||||
engine = data.aws_rds_engine_version.postgresql.engine
|
||||
engine_mode = "provisioned"
|
||||
engine_version = data.aws_rds_engine_version.postgresql.version
|
||||
storage_encrypted = true
|
||||
master_username = "formbricks"
|
||||
master_password = random_password.postgres[each.key].result
|
||||
manage_master_user_password = false
|
||||
create_db_cluster_parameter_group = true
|
||||
db_cluster_parameter_group_family = data.aws_rds_engine_version.postgresql.parameter_group_family
|
||||
db_cluster_parameter_group_parameters = [
|
||||
{
|
||||
name = "shared_preload_libraries"
|
||||
value = "pglogical"
|
||||
apply_method = "pending-reboot"
|
||||
}
|
||||
]
|
||||
|
||||
vpc_id = module.vpc.vpc_id
|
||||
db_subnet_group_name = module.vpc.database_subnet_group_name
|
||||
security_group_rules = {
|
||||
vpc_ingress = {
|
||||
cidr_blocks = [module.vpc.vpc_cidr_block]
|
||||
}
|
||||
}
|
||||
performance_insights_enabled = true
|
||||
cluster_performance_insights_enabled = true
|
||||
|
||||
backup_retention_period = 7
|
||||
apply_immediately = true
|
||||
skip_final_snapshot = false
|
||||
|
||||
deletion_protection = true
|
||||
|
||||
enable_http_endpoint = true
|
||||
|
||||
serverlessv2_scaling_configuration = {
|
||||
min_capacity = 0.5
|
||||
max_capacity = 50
|
||||
}
|
||||
|
||||
instance_class = "db.serverless"
|
||||
|
||||
instances = {
|
||||
one = {}
|
||||
}
|
||||
|
||||
tags = local.tags_map[each.key]
|
||||
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
# Create the first AWS Secrets Manager secret for environment variables
|
||||
moved {
|
||||
from = aws_secretsmanager_secret.formbricks_app_secrets
|
||||
to = aws_secretsmanager_secret.formbricks_app_secrets["prod"]
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "formbricks_app_secrets" {
|
||||
for_each = local.envs
|
||||
name = "${each.key}/formbricks/secrets"
|
||||
}
|
||||
|
||||
moved {
|
||||
from = aws_secretsmanager_secret_version.formbricks_app_secrets
|
||||
to = aws_secretsmanager_secret_version.formbricks_app_secrets["prod"]
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "formbricks_app_secrets" {
|
||||
for_each = local.envs
|
||||
secret_id = aws_secretsmanager_secret.formbricks_app_secrets[each.key].id
|
||||
secret_string = jsonencode({
|
||||
REDIS_URL = "rediss://${each.value}:${random_password.valkey[each.key].result}@${module.valkey_serverless[each.key].serverless_cache_endpoint[0].address}:6379"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
#
|
||||
@@ -1,18 +0,0 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.46"
|
||||
}
|
||||
kubernetes = {
|
||||
source = "hashicorp/kubernetes"
|
||||
version = "~> 2.36"
|
||||
}
|
||||
helm = {
|
||||
source = "hashicorp/helm"
|
||||
version = "~> 2.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/* eslint-disable no-constant-condition -- Required for the while loop */
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Required for a while loop here */
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { MigrationScript } from "../../src/scripts/migration-runner";
|
||||
|
||||
export const addLanguageAttributeKey: MigrationScript = {
|
||||
type: "data",
|
||||
id: "add_language_attribute_key_v1",
|
||||
name: "20251016000000_add_language_attribute_key",
|
||||
run: async ({ tx }) => {
|
||||
const BATCH_SIZE = 1000;
|
||||
let skip = 0;
|
||||
let totalProcessed = 0;
|
||||
|
||||
logger.info("Starting migration to add language attribute key to environments");
|
||||
|
||||
while (true) {
|
||||
// Fetch environments in batches
|
||||
const environments = await tx.$queryRaw<{ id: string }[]>`
|
||||
SELECT id FROM "Environment"
|
||||
LIMIT ${BATCH_SIZE} OFFSET ${skip}
|
||||
`;
|
||||
|
||||
if (environments.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info(`Processing ${environments.length.toString()} environments`);
|
||||
|
||||
// Process each environment
|
||||
for (const env of environments) {
|
||||
// Insert language attribute key if it doesn't exist
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "ContactAttributeKey" (
|
||||
"id", "created_at", "updated_at", "key", "name", "description", "type", "isUnique", "environmentId"
|
||||
) VALUES (
|
||||
${createId()},
|
||||
NOW(),
|
||||
NOW(),
|
||||
'language',
|
||||
'Language',
|
||||
'The language preference of a contact',
|
||||
'default',
|
||||
false,
|
||||
${env.id}
|
||||
)
|
||||
ON CONFLICT ("key", "environmentId") DO NOTHING
|
||||
`;
|
||||
}
|
||||
|
||||
totalProcessed += environments.length;
|
||||
skip += BATCH_SIZE;
|
||||
|
||||
logger.info(`Processed ${totalProcessed.toString()} environments so far`);
|
||||
}
|
||||
|
||||
logger.info(`Migration completed. Total environments processed: ${totalProcessed.toString()}`);
|
||||
},
|
||||
};
|
||||
@@ -54,7 +54,9 @@ checksums:
|
||||
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4
|
||||
errors/invalid_device_error/title: 20d261b478aaba161b0853a588926e23
|
||||
errors/please_book_an_appointment: 9e8acea3721f660b6a988f79c4105ab8
|
||||
errors/please_enter_a_valid_email_address: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||
errors/please_enter_a_valid_phone_number: 1530eb9ab7d6d190bddb37667c711631
|
||||
errors/please_enter_a_valid_url: e3bcfb605be4ee32aa19d9ac32bb11a4
|
||||
errors/please_fill_out_this_field: 88d4fd502ae8d423277aef723afcd1a7
|
||||
errors/please_rank_all_items_before_submitting: 24fb14a2550bd7ec3e253dda0997cea8
|
||||
errors/please_select_a_date: 1abdc8ffb887dbbdcc0d05486cd84de7
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"title": "هذا الجهاز لا يدعم حماية البريد المزعج."
|
||||
},
|
||||
"please_book_an_appointment": "يرجى حجز موعد",
|
||||
"please_enter_a_valid_email_address": "الرجاء إدخال عنوان بريد إلكتروني صالح",
|
||||
"please_enter_a_valid_phone_number": "يرجى إدخال رقم هاتف صحيح",
|
||||
"please_enter_a_valid_url": "الرجاء إدخال عنوان URL صالح",
|
||||
"please_fill_out_this_field": "يرجى ملء هذا الحقل",
|
||||
"please_rank_all_items_before_submitting": "يرجى ترتيب جميع العناصر قبل الإرسال",
|
||||
"please_select_a_date": "يرجى اختيار تاريخ",
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"title": "Dieses Gerät unterstützt keinen Spam-Schutz."
|
||||
},
|
||||
"please_book_an_appointment": "Bitte vereinbaren Sie einen Termin",
|
||||
"please_enter_a_valid_email_address": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"please_enter_a_valid_phone_number": "Bitte geben Sie eine gültige Telefonnummer ein",
|
||||
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein",
|
||||
"please_fill_out_this_field": "Bitte füllen Sie dieses Feld aus",
|
||||
"please_rank_all_items_before_submitting": "Bitte ordnen Sie alle Elemente vor dem Absenden",
|
||||
"please_select_a_date": "Bitte wählen Sie ein Datum aus",
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"title": "This device doesn’t support spam protection."
|
||||
},
|
||||
"please_book_an_appointment": "Please book an appointment",
|
||||
"please_enter_a_valid_email_address": "Please enter a valid email address",
|
||||
"please_enter_a_valid_phone_number": "Please enter a valid phone number",
|
||||
"please_enter_a_valid_url": "Please enter a valid URL",
|
||||
"please_fill_out_this_field": "Please fill out this field",
|
||||
"please_rank_all_items_before_submitting": "Please rank all items before submitting",
|
||||
"please_select_a_date": "Please select a date",
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"title": "Este dispositivo no es compatible con la protección contra spam."
|
||||
},
|
||||
"please_book_an_appointment": "Por favor, reserve una cita",
|
||||
"please_enter_a_valid_email_address": "Por favor, introduce una dirección de correo electrónico válida",
|
||||
"please_enter_a_valid_phone_number": "Por favor, introduzca un número de teléfono válido",
|
||||
"please_enter_a_valid_url": "Por favor, introduce una URL válida",
|
||||
"please_fill_out_this_field": "Por favor, complete este campo",
|
||||
"please_rank_all_items_before_submitting": "Por favor, clasifique todos los elementos antes de enviar",
|
||||
"please_select_a_date": "Por favor, seleccione una fecha",
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"title": "Cet appareil ne prend pas en charge la protection contre le spam."
|
||||
},
|
||||
"please_book_an_appointment": "Veuillez prendre rendez-vous",
|
||||
"please_enter_a_valid_email_address": "Veuillez saisir une adresse e-mail valide",
|
||||
"please_enter_a_valid_phone_number": "Veuillez saisir un numéro de téléphone valide",
|
||||
"please_enter_a_valid_url": "Veuillez saisir une URL valide",
|
||||
"please_fill_out_this_field": "Veuillez remplir ce champ",
|
||||
"please_rank_all_items_before_submitting": "Veuillez classer tous les éléments avant de soumettre",
|
||||
"please_select_a_date": "Veuillez sélectionner une date",
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"title": "यह डिवाइस स्पैम सुरक्षा का समर्थन नहीं करता है।"
|
||||
},
|
||||
"please_book_an_appointment": "कृपया एक अपॉइंटमेंट बुक करें",
|
||||
"please_enter_a_valid_email_address": "कृपया एक वैध ईमेल पता दर्ज करें",
|
||||
"please_enter_a_valid_phone_number": "कृपया एक वैध फोन नंबर दर्ज करें",
|
||||
"please_enter_a_valid_url": "कृपया एक वैध URL दर्ज करें",
|
||||
"please_fill_out_this_field": "कृपया इस फील्ड को भरें",
|
||||
"please_rank_all_items_before_submitting": "जमा करने से पहले कृपया सभी आइटम्स को रैंक करें",
|
||||
"please_select_a_date": "कृपया एक तारीख चुनें",
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"title": "Questo dispositivo non supporta la protezione anti-spam."
|
||||
},
|
||||
"please_book_an_appointment": "Prenota un appuntamento",
|
||||
"please_enter_a_valid_email_address": "Inserisci un indirizzo email valido",
|
||||
"please_enter_a_valid_phone_number": "Inserisci un numero di telefono valido",
|
||||
"please_enter_a_valid_url": "Inserisci un URL valido",
|
||||
"please_fill_out_this_field": "Compila questo campo",
|
||||
"please_rank_all_items_before_submitting": "Classifica tutti gli elementi prima di inviare",
|
||||
"please_select_a_date": "Seleziona una data",
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"title": "このデバイスはスパム保護に対応していません。"
|
||||
},
|
||||
"please_book_an_appointment": "予約をお取りください",
|
||||
"please_enter_a_valid_email_address": "有効なメールアドレスを入力してください",
|
||||
"please_enter_a_valid_phone_number": "有効な電話番号を入力してください",
|
||||
"please_enter_a_valid_url": "有効なURLを入力してください",
|
||||
"please_fill_out_this_field": "このフィールドに入力してください",
|
||||
"please_rank_all_items_before_submitting": "送信する前にすべての項目をランク付けしてください",
|
||||
"please_select_a_date": "日付を選択してください",
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"title": "Este dispositivo não suporta proteção contra spam."
|
||||
},
|
||||
"please_book_an_appointment": "Por favor, marque uma consulta",
|
||||
"please_enter_a_valid_email_address": "Por favor, insira um endereço de email válido",
|
||||
"please_enter_a_valid_phone_number": "Por favor, insira um número de telefone válido",
|
||||
"please_enter_a_valid_url": "Por favor, insira uma URL válida",
|
||||
"please_fill_out_this_field": "Por favor, preencha este campo",
|
||||
"please_rank_all_items_before_submitting": "Por favor, classifique todos os itens antes de enviar",
|
||||
"please_select_a_date": "Por favor, selecione uma data",
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"title": "Acest dispozitiv nu acceptă protecția împotriva spamului."
|
||||
},
|
||||
"please_book_an_appointment": "Vă rugăm să faceți o programare",
|
||||
"please_enter_a_valid_email_address": "Vă rugăm să introduceți o adresă de email validă",
|
||||
"please_enter_a_valid_phone_number": "Vă rugăm să introduceți un număr de telefon valid",
|
||||
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid",
|
||||
"please_fill_out_this_field": "Vă rugăm să completați acest câmp",
|
||||
"please_rank_all_items_before_submitting": "Vă rugăm să clasificați toate elementele înainte de a trimite",
|
||||
"please_select_a_date": "Vă rugăm să selectați o dată",
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"title": "Это устройство не поддерживает защиту от спама."
|
||||
},
|
||||
"please_book_an_appointment": "Пожалуйста, запишитесь на приём",
|
||||
"please_enter_a_valid_email_address": "Пожалуйста, введите действительный адрес электронной почты",
|
||||
"please_enter_a_valid_phone_number": "Пожалуйста, введите действительный номер телефона",
|
||||
"please_enter_a_valid_url": "Пожалуйста, введите действительный URL-адрес",
|
||||
"please_fill_out_this_field": "Пожалуйста, заполните это поле",
|
||||
"please_rank_all_items_before_submitting": "Пожалуйста, оцените все элементы перед отправкой",
|
||||
"please_select_a_date": "Пожалуйста, выберите дату",
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"title": "Ushbu qurilma spam himoyasini qo'llab-quvvatlamaydi."
|
||||
},
|
||||
"please_book_an_appointment": "Iltimos, uchrashuvni bron qiling",
|
||||
"please_enter_a_valid_email_address": "Iltimos, toʻgʻri elektron pochta manzilini kiriting",
|
||||
"please_enter_a_valid_phone_number": "Iltimos, to'g'ri telefon raqamini kiriting",
|
||||
"please_enter_a_valid_url": "Iltimos, toʻgʻri URL manzilini kiriting",
|
||||
"please_fill_out_this_field": "Iltimos, ushbu maydonni to'ldiring",
|
||||
"please_rank_all_items_before_submitting": "Iltimos, yuborishdan oldin barcha elementlarni baholang",
|
||||
"please_select_a_date": "Iltimos, sanani tanlang",
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"title": "此设备不支持垃圾邮件保护。"
|
||||
},
|
||||
"please_book_an_appointment": "请预约",
|
||||
"please_enter_a_valid_email_address": "请输入有效的电子邮件地址",
|
||||
"please_enter_a_valid_phone_number": "请输入有效的电话号码",
|
||||
"please_enter_a_valid_url": "请输入有效的URL",
|
||||
"please_fill_out_this_field": "请填写此字段",
|
||||
"please_rank_all_items_before_submitting": "请在提交之前对所有项目进行排名",
|
||||
"please_select_a_date": "请选择一个日期",
|
||||
|
||||
@@ -454,4 +454,328 @@ describe("OpenTextQuestion", () => {
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea).not.toHaveAttribute("autoFocus");
|
||||
});
|
||||
|
||||
test("prevents submission when required field is empty", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const setCustomValidityMock = vi.fn();
|
||||
const reportValidityMock = vi.fn(() => true);
|
||||
|
||||
const setCustomValiditySpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
|
||||
.mockImplementation(setCustomValidityMock);
|
||||
const reportValiditySpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, "reportValidity")
|
||||
.mockImplementation(reportValidityMock);
|
||||
|
||||
const { container } = render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
value=""
|
||||
onSubmit={onSubmit}
|
||||
question={{ ...defaultQuestion, required: true }}
|
||||
/>
|
||||
);
|
||||
|
||||
const form = container.querySelector("form");
|
||||
fireEvent.submit(form!);
|
||||
|
||||
expect(setCustomValidityMock).toHaveBeenCalledWith("errors.please_fill_out_this_field");
|
||||
expect(reportValidityMock).toHaveBeenCalled();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
setCustomValiditySpy.mockRestore();
|
||||
reportValiditySpy.mockRestore();
|
||||
});
|
||||
|
||||
test("prevents submission when required field contains only whitespace", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const setCustomValidityMock = vi.fn();
|
||||
const reportValidityMock = vi.fn(() => true);
|
||||
|
||||
const setCustomValiditySpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
|
||||
.mockImplementation(setCustomValidityMock);
|
||||
const reportValiditySpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, "reportValidity")
|
||||
.mockImplementation(reportValidityMock);
|
||||
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
value=" "
|
||||
onSubmit={onSubmit}
|
||||
question={{ ...defaultQuestion, required: true }}
|
||||
/>
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(setCustomValidityMock).toHaveBeenCalledWith("errors.please_fill_out_this_field");
|
||||
expect(reportValidityMock).toHaveBeenCalled();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
setCustomValiditySpy.mockRestore();
|
||||
reportValiditySpy.mockRestore();
|
||||
});
|
||||
|
||||
test("prevents submission with invalid email", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const setCustomValidityMock = vi.fn();
|
||||
const reportValidityMock = vi.fn(() => true);
|
||||
|
||||
const setCustomValiditySpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
|
||||
.mockImplementation(setCustomValidityMock);
|
||||
const reportValiditySpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, "reportValidity")
|
||||
.mockImplementation(reportValidityMock);
|
||||
|
||||
const { container } = render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
value="invalid-email"
|
||||
onSubmit={onSubmit}
|
||||
question={{ ...defaultQuestion, inputType: "email" }}
|
||||
/>
|
||||
);
|
||||
|
||||
const form = container.querySelector("form");
|
||||
fireEvent.submit(form!);
|
||||
|
||||
expect(setCustomValidityMock).toHaveBeenCalledWith("errors.please_enter_a_valid_email_address");
|
||||
expect(reportValidityMock).toHaveBeenCalled();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
setCustomValiditySpy.mockRestore();
|
||||
reportValiditySpy.mockRestore();
|
||||
});
|
||||
|
||||
test("allows submission with valid email", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const setTtc = vi.fn();
|
||||
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
value="test@example.com"
|
||||
onSubmit={onSubmit}
|
||||
setTtc={setTtc}
|
||||
question={{ ...defaultQuestion, inputType: "email" }}
|
||||
/>
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({ q1: "test@example.com" }, {});
|
||||
expect(setTtc).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("prevents submission with invalid URL", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const setCustomValidityMock = vi.fn();
|
||||
const reportValidityMock = vi.fn(() => true);
|
||||
|
||||
const setCustomValiditySpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
|
||||
.mockImplementation(setCustomValidityMock);
|
||||
const reportValiditySpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, "reportValidity")
|
||||
.mockImplementation(reportValidityMock);
|
||||
|
||||
const { container } = render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
value="not-a-url"
|
||||
onSubmit={onSubmit}
|
||||
question={{ ...defaultQuestion, inputType: "url" }}
|
||||
/>
|
||||
);
|
||||
|
||||
const form = container.querySelector("form");
|
||||
fireEvent.submit(form!);
|
||||
|
||||
expect(setCustomValidityMock).toHaveBeenCalledWith("errors.please_enter_a_valid_url");
|
||||
expect(reportValidityMock).toHaveBeenCalled();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
setCustomValiditySpy.mockRestore();
|
||||
reportValiditySpy.mockRestore();
|
||||
});
|
||||
|
||||
test("allows submission with valid URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const setTtc = vi.fn();
|
||||
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
value="https://example.com"
|
||||
onSubmit={onSubmit}
|
||||
setTtc={setTtc}
|
||||
question={{ ...defaultQuestion, inputType: "url" }}
|
||||
/>
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({ q1: "https://example.com" }, {});
|
||||
expect(setTtc).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("prevents submission with invalid phone number", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const setCustomValidityMock = vi.fn();
|
||||
const reportValidityMock = vi.fn(() => true);
|
||||
|
||||
const setCustomValiditySpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
|
||||
.mockImplementation(setCustomValidityMock);
|
||||
const reportValiditySpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, "reportValidity")
|
||||
.mockImplementation(reportValidityMock);
|
||||
|
||||
const { container } = render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
value="abc"
|
||||
onSubmit={onSubmit}
|
||||
question={{ ...defaultQuestion, inputType: "phone" }}
|
||||
/>
|
||||
);
|
||||
|
||||
const form = container.querySelector("form");
|
||||
fireEvent.submit(form!);
|
||||
|
||||
expect(setCustomValidityMock).toHaveBeenCalledWith("errors.please_enter_a_valid_phone_number");
|
||||
expect(reportValidityMock).toHaveBeenCalled();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
setCustomValiditySpy.mockRestore();
|
||||
reportValiditySpy.mockRestore();
|
||||
});
|
||||
|
||||
test("allows submission with valid phone number", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const setTtc = vi.fn();
|
||||
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
value="+1234567890"
|
||||
onSubmit={onSubmit}
|
||||
setTtc={setTtc}
|
||||
question={{ ...defaultQuestion, inputType: "phone" }}
|
||||
/>
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({ q1: "+1234567890" }, {});
|
||||
expect(setTtc).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows submission with valid phone number with spaces and dashes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const setTtc = vi.fn();
|
||||
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
value="+1 234 567 890"
|
||||
onSubmit={onSubmit}
|
||||
setTtc={setTtc}
|
||||
question={{ ...defaultQuestion, inputType: "phone" }}
|
||||
/>
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({ q1: "+1 234 567 890" }, {});
|
||||
expect(setTtc).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("prevents submission with phone number that is too short", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const setCustomValidityMock = vi.fn();
|
||||
const reportValidityMock = vi.fn(() => true);
|
||||
|
||||
const setCustomValiditySpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
|
||||
.mockImplementation(setCustomValidityMock);
|
||||
const reportValiditySpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, "reportValidity")
|
||||
.mockImplementation(reportValidityMock);
|
||||
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
value="123"
|
||||
onSubmit={onSubmit}
|
||||
question={{ ...defaultQuestion, inputType: "phone" }}
|
||||
/>
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(setCustomValidityMock).toHaveBeenCalledWith("errors.please_enter_a_valid_phone_number");
|
||||
expect(reportValidityMock).toHaveBeenCalled();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
setCustomValiditySpy.mockRestore();
|
||||
reportValiditySpy.mockRestore();
|
||||
});
|
||||
|
||||
test("clears custom validity on input change", async () => {
|
||||
const onChange = vi.fn();
|
||||
const setCustomValidityMock = vi.fn();
|
||||
|
||||
const setCustomValiditySpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
|
||||
.mockImplementation(setCustomValidityMock);
|
||||
|
||||
render(<OpenTextQuestion {...defaultProps} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type here...");
|
||||
|
||||
fireEvent.input(input, { target: { value: "Test" } });
|
||||
|
||||
expect(setCustomValidityMock).toHaveBeenCalledWith("");
|
||||
expect(onChange).toHaveBeenCalledWith({ q1: "Test" });
|
||||
|
||||
setCustomValiditySpy.mockRestore();
|
||||
});
|
||||
|
||||
test("allows submission of optional empty field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const setTtc = vi.fn();
|
||||
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
value=""
|
||||
onSubmit={onSubmit}
|
||||
setTtc={setTtc}
|
||||
question={{ ...defaultQuestion, required: false }}
|
||||
/>
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({ q1: "" }, {});
|
||||
expect(setTtc).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { type RefObject } from "preact";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ZEmail, ZUrl } from "@formbricks/types/common";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { BackButton } from "@/components/buttons/back-button";
|
||||
import { SubmitButton } from "@/components/buttons/submit-button";
|
||||
import { Headline } from "@/components/general/headline";
|
||||
@@ -6,11 +12,6 @@ import { Subheader } from "@/components/general/subheader";
|
||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { type RefObject } from "preact";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface OpenTextQuestionProps {
|
||||
question: TSurveyOpenTextQuestion;
|
||||
@@ -52,7 +53,6 @@ export function OpenTextQuestion({
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,7 +78,29 @@ export function OpenTextQuestion({
|
||||
return;
|
||||
}
|
||||
|
||||
// at this point, validity is clean
|
||||
if (value && value.trim() !== "") {
|
||||
if (question.inputType === "email") {
|
||||
if (!ZEmail.safeParse(value).success) {
|
||||
input?.setCustomValidity(t("errors.please_enter_a_valid_email_address"));
|
||||
input?.reportValidity();
|
||||
return;
|
||||
}
|
||||
} else if (question.inputType === "url") {
|
||||
if (!ZUrl.safeParse(value).success) {
|
||||
input?.setCustomValidity(t("errors.please_enter_a_valid_url"));
|
||||
input?.reportValidity();
|
||||
return;
|
||||
}
|
||||
} else if (question.inputType === "phone") {
|
||||
const phoneRegex = /^[+]?[\d\s\-()]{7,}$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
input?.setCustomValidity(t("errors.please_enter_a_valid_phone_number"));
|
||||
input?.reportValidity();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedTtc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtc);
|
||||
onSubmit({ [question.id]: value }, updatedTtc);
|
||||
@@ -114,12 +136,20 @@ export function OpenTextQuestion({
|
||||
value={value ? value : ""}
|
||||
type={question.inputType}
|
||||
onInput={(e) => {
|
||||
handleInputChange(e.currentTarget.value);
|
||||
const input = e.currentTarget;
|
||||
handleInputChange(input.value);
|
||||
input.setCustomValidity("");
|
||||
}}
|
||||
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm"
|
||||
pattern={question.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*"}
|
||||
title={
|
||||
question.inputType === "phone" ? t("errors.please_enter_a_valid_phone_number") : undefined
|
||||
question.inputType === "phone"
|
||||
? t("errors.please_enter_a_valid_phone_number")
|
||||
: question.inputType === "email"
|
||||
? t("errors.please_enter_a_valid_email_address")
|
||||
: question.inputType === "url"
|
||||
? t("errors.please_enter_a_valid_url")
|
||||
: undefined
|
||||
}
|
||||
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
|
||||
maxLength={
|
||||
|
||||
@@ -4,6 +4,8 @@ export const ZBoolean = z.boolean();
|
||||
|
||||
export const ZString = z.string();
|
||||
|
||||
export const ZUrl = z.string().url();
|
||||
|
||||
export const ZNumber = z.number();
|
||||
|
||||
export const ZOptionalNumber = z.number().optional();
|
||||
@@ -178,3 +180,5 @@ export const safeUrlRefinement = (url: string, ctx: z.RefinementCtx): void => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const ZEmail = z.string().email();
|
||||
|
||||
Reference in New Issue
Block a user