Compare commits
23 Commits
simplify-p
...
chore/infr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
208eb7ce2d | ||
|
|
ec208960e8 | ||
|
|
b9505158b4 | ||
|
|
ad0c3421f0 | ||
|
|
916c00344b | ||
|
|
459cdee17e | ||
|
|
bb26a64dbb | ||
|
|
29a3fa532a | ||
|
|
738b8f9012 | ||
|
|
c95272288e | ||
|
|
919febd166 | ||
|
|
10ccc20b53 | ||
|
|
d9ca64da54 | ||
|
|
ce00ec97d1 | ||
|
|
2b9cd37c6c | ||
|
|
f8f14eb6f3 | ||
|
|
645fc863aa | ||
|
|
c53f030b24 | ||
|
|
45d74f9ba0 | ||
|
|
87870919ca | ||
|
|
ce2fdde474 | ||
|
|
6e2f30c6ed | ||
|
|
5c8040008a |
61
.cursor/rules/build-and-deployment.mdc
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Build & Deployment Best Practices
|
||||
|
||||
## Build Process
|
||||
|
||||
### Running Builds
|
||||
- Use `pnpm build` from project root for full build
|
||||
- Monitor for React hooks warnings and fix them immediately
|
||||
- Ensure all TypeScript errors are resolved before deployment
|
||||
|
||||
### Common Build Issues & Fixes
|
||||
|
||||
#### React Hooks Warnings
|
||||
- Capture ref values in variables within useEffect cleanup
|
||||
- Avoid accessing `.current` directly in cleanup functions
|
||||
- Pattern for fixing ref cleanup warnings:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const currentRef = myRef.current;
|
||||
return () => {
|
||||
if (currentRef) {
|
||||
currentRef.cleanup();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
#### Test Failures During Build
|
||||
- Ensure all test mocks include required constants like `SESSION_MAX_AGE`
|
||||
- Mock Next.js navigation hooks properly: `useParams`, `useRouter`, `useSearchParams`
|
||||
- Remove unused imports and constants from test files
|
||||
- Use literal values instead of imported constants when the constant isn't actually needed
|
||||
|
||||
### Test Execution
|
||||
- Run `pnpm test` to execute all tests
|
||||
- Use `pnpm test -- --run filename.test.tsx` for specific test files
|
||||
- Fix test failures before merging code
|
||||
- Ensure 100% test coverage for new components
|
||||
|
||||
### Performance Monitoring
|
||||
- Monitor build times and optimize if necessary
|
||||
- Watch for memory usage during builds
|
||||
- Use proper caching strategies for faster rebuilds
|
||||
|
||||
### Deployment Checklist
|
||||
1. All tests passing
|
||||
2. Build completes without warnings
|
||||
3. TypeScript compilation successful
|
||||
4. No linter errors
|
||||
5. Database migrations applied (if any)
|
||||
6. Environment variables configured
|
||||
|
||||
### EKS Deployment Considerations
|
||||
- Ensure latest code is deployed to all pods
|
||||
- Monitor AWS RDS Performance Insights for database issues
|
||||
- Verify environment-specific configurations
|
||||
- Check pod health and resource usage
|
||||
41
.cursor/rules/database-performance.mdc
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Database Performance & Prisma Best Practices
|
||||
|
||||
## Critical Performance Rules
|
||||
|
||||
### Response Count Queries
|
||||
- **NEVER** use `skip`/`offset` with `prisma.response.count()` - this causes expensive subqueries with OFFSET
|
||||
- Always use only `where` clauses for count operations: `prisma.response.count({ where: { ... } })`
|
||||
- For pagination, separate count queries from data queries
|
||||
- Reference: [apps/web/lib/response/service.ts](mdc:apps/web/lib/response/service.ts) line 654-686
|
||||
|
||||
### Prisma Query Optimization
|
||||
- Use proper indexes defined in [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
|
||||
- Leverage existing indexes: `@@index([surveyId, createdAt])`, `@@index([createdAt])`
|
||||
- Use cursor-based pagination for large datasets instead of offset-based
|
||||
- Cache frequently accessed data using React Cache and custom cache tags
|
||||
|
||||
### Date Range Filtering
|
||||
- When filtering by `createdAt`, always use indexed queries
|
||||
- Combine with `surveyId` for optimal performance: `{ surveyId, createdAt: { gte: start, lt: end } }`
|
||||
- Avoid complex WHERE clauses that can't utilize indexes
|
||||
|
||||
### Count vs Data Separation
|
||||
- Always separate count queries from data fetching queries
|
||||
- Use `Promise.all()` to run count and data queries in parallel
|
||||
- Example pattern from [apps/web/modules/api/v2/management/responses/lib/response.ts](mdc:apps/web/modules/api/v2/management/responses/lib/response.ts):
|
||||
```typescript
|
||||
const [responses, totalCount] = await Promise.all([
|
||||
prisma.response.findMany(query),
|
||||
prisma.response.count({ where: whereClause }),
|
||||
]);
|
||||
```
|
||||
|
||||
### Monitoring & Debugging
|
||||
- Monitor AWS RDS Performance Insights for problematic queries
|
||||
- Look for queries with OFFSET in count operations - these indicate performance issues
|
||||
- Use proper error handling with `DatabaseError` for Prisma exceptions
|
||||
334
.cursor/rules/formbricks-architecture.mdc
Normal file
@@ -0,0 +1,334 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Formbricks Architecture & Patterns
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
### Apps Directory
|
||||
- `apps/web/` - Main Next.js web application
|
||||
- `packages/` - Shared packages and utilities
|
||||
|
||||
### Key Directories in Web App
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Next.js 13+ app directory
|
||||
│ ├── (app)/ # Main application routes
|
||||
│ ├── (auth)/ # Authentication routes
|
||||
│ ├── api/ # API routes
|
||||
│ └── share/ # Public sharing routes
|
||||
├── components/ # Shared components
|
||||
├── lib/ # Utility functions and services
|
||||
└── modules/ # Feature-specific modules
|
||||
```
|
||||
|
||||
## Routing Patterns
|
||||
|
||||
### App Router Structure
|
||||
The application uses Next.js 13+ app router with route groups:
|
||||
|
||||
```
|
||||
(app)/environments/[environmentId]/
|
||||
├── surveys/[surveyId]/
|
||||
│ ├── (analysis)/ # Analysis views
|
||||
│ │ ├── responses/ # Response management
|
||||
│ │ ├── summary/ # Survey summary
|
||||
│ │ └── hooks/ # Analysis-specific hooks
|
||||
│ ├── edit/ # Survey editing
|
||||
│ └── settings/ # Survey settings
|
||||
```
|
||||
|
||||
### Dynamic Routes
|
||||
- `[environmentId]` - Environment-specific routes
|
||||
- `[surveyId]` - Survey-specific routes
|
||||
- `[sharingKey]` - Public sharing routes
|
||||
|
||||
## Service Layer Pattern
|
||||
|
||||
### Service Organization
|
||||
Services are organized by domain in `apps/web/lib/`:
|
||||
|
||||
```typescript
|
||||
// Example: Response service
|
||||
// apps/web/lib/response/service.ts
|
||||
export const getResponseCountAction = async ({
|
||||
surveyId,
|
||||
filterCriteria,
|
||||
}: {
|
||||
surveyId: string;
|
||||
filterCriteria: any;
|
||||
}) => {
|
||||
// Service implementation
|
||||
};
|
||||
```
|
||||
|
||||
### Action Pattern
|
||||
Server actions follow a consistent pattern:
|
||||
|
||||
```typescript
|
||||
// Action wrapper for service calls
|
||||
export const getResponseCountAction = async (params) => {
|
||||
try {
|
||||
const result = await responseService.getCount(params);
|
||||
return { data: result };
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Context Patterns
|
||||
|
||||
### Provider Structure
|
||||
Context providers follow a consistent pattern:
|
||||
|
||||
```typescript
|
||||
// Provider component
|
||||
export const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [selectedFilter, setSelectedFilter] = useState(defaultFilter);
|
||||
|
||||
const value = {
|
||||
selectedFilter,
|
||||
setSelectedFilter,
|
||||
// ... other state and methods
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponseFilterContext.Provider value={value}>
|
||||
{children}
|
||||
</ResponseFilterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook for consuming context
|
||||
export const useResponseFilter = () => {
|
||||
const context = useContext(ResponseFilterContext);
|
||||
if (!context) {
|
||||
throw new Error('useResponseFilter must be used within ResponseFilterProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
```
|
||||
|
||||
### Context Composition
|
||||
Multiple contexts are often composed together:
|
||||
|
||||
```typescript
|
||||
// Layout component with multiple providers
|
||||
export default function AnalysisLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ResponseFilterProvider>
|
||||
<ResponseCountProvider>
|
||||
{children}
|
||||
</ResponseCountProvider>
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Page Components
|
||||
Page components are located in the app directory and follow this pattern:
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx
|
||||
export default function ResponsesPage() {
|
||||
return (
|
||||
<div>
|
||||
<ResponsesTable />
|
||||
<ResponsesPagination />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component Organization
|
||||
- **Pages** - Route components in app directory
|
||||
- **Components** - Reusable UI components
|
||||
- **Modules** - Feature-specific components and logic
|
||||
|
||||
### Shared Components
|
||||
Common components are in `apps/web/components/`:
|
||||
- UI components (buttons, inputs, modals)
|
||||
- Layout components (headers, sidebars)
|
||||
- Data display components (tables, charts)
|
||||
|
||||
## Hook Patterns
|
||||
|
||||
### Custom Hook Structure
|
||||
Custom hooks follow consistent patterns:
|
||||
|
||||
```typescript
|
||||
export const useResponseCount = ({
|
||||
survey,
|
||||
initialCount
|
||||
}: {
|
||||
survey: TSurvey;
|
||||
initialCount?: number;
|
||||
}) => {
|
||||
const [responseCount, setResponseCount] = useState(initialCount ?? 0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Hook logic...
|
||||
|
||||
return {
|
||||
responseCount,
|
||||
isLoading,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Hook Dependencies
|
||||
- Use context hooks for shared state
|
||||
- Implement proper cleanup with AbortController
|
||||
- Optimize dependency arrays to prevent unnecessary re-renders
|
||||
|
||||
## Data Fetching Patterns
|
||||
|
||||
### Server Actions
|
||||
The app uses Next.js server actions for data fetching:
|
||||
|
||||
```typescript
|
||||
// Server action
|
||||
export async function getResponsesAction(params: GetResponsesParams) {
|
||||
const responses = await getResponses(params);
|
||||
return { data: responses };
|
||||
}
|
||||
|
||||
// Client usage
|
||||
const { data } = await getResponsesAction(params);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
Consistent error handling across the application:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await apiCall();
|
||||
return { data: result };
|
||||
} catch (error) {
|
||||
console.error("Operation failed:", error);
|
||||
return { error: error.message };
|
||||
}
|
||||
```
|
||||
|
||||
## Type Safety
|
||||
|
||||
### Type Organization
|
||||
Types are organized in packages:
|
||||
- `@formbricks/types` - Shared type definitions
|
||||
- Local types in component/hook files
|
||||
|
||||
### Common Types
|
||||
```typescript
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Local State
|
||||
- Use `useState` for component-specific state
|
||||
- Use `useReducer` for complex state logic
|
||||
- Use refs for mutable values that don't trigger re-renders
|
||||
|
||||
### Global State
|
||||
- React Context for feature-specific shared state
|
||||
- URL state for filters and pagination
|
||||
- Server state through server actions
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Code Splitting
|
||||
- Dynamic imports for heavy components
|
||||
- Route-based code splitting with app router
|
||||
- Lazy loading for non-critical features
|
||||
|
||||
### Caching Strategy
|
||||
- Server-side caching for database queries
|
||||
- Client-side caching with React Query (where applicable)
|
||||
- Static generation for public pages
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Test Organization
|
||||
```
|
||||
component/
|
||||
├── Component.tsx
|
||||
├── Component.test.tsx
|
||||
└── hooks/
|
||||
├── useHook.ts
|
||||
└── useHook.test.tsx
|
||||
```
|
||||
|
||||
### Test Patterns
|
||||
- Unit tests for utilities and services
|
||||
- Integration tests for components with context
|
||||
- Hook tests with proper mocking
|
||||
|
||||
## Build & Deployment
|
||||
|
||||
### Build Process
|
||||
- TypeScript compilation
|
||||
- Next.js build optimization
|
||||
- Asset optimization and bundling
|
||||
|
||||
### Environment Configuration
|
||||
- Environment-specific configurations
|
||||
- Feature flags for gradual rollouts
|
||||
- Database connection management
|
||||
|
||||
## Security Patterns
|
||||
|
||||
### Authentication
|
||||
- Session-based authentication
|
||||
- Environment-based access control
|
||||
- API route protection
|
||||
|
||||
### Data Validation
|
||||
- Input validation on both client and server
|
||||
- Type-safe API contracts
|
||||
- Sanitization of user inputs
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### Error Tracking
|
||||
- Client-side error boundaries
|
||||
- Server-side error logging
|
||||
- Performance monitoring
|
||||
|
||||
### Analytics
|
||||
- User interaction tracking
|
||||
- Performance metrics
|
||||
- Database query monitoring
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
### Code Organization
|
||||
- ✅ Follow the established directory structure
|
||||
- ✅ Use consistent naming conventions
|
||||
- ✅ Separate concerns (UI, logic, data)
|
||||
- ✅ Keep components focused and small
|
||||
|
||||
### Performance
|
||||
- ✅ Implement proper loading states
|
||||
- ✅ Use AbortController for async operations
|
||||
- ✅ Optimize database queries
|
||||
- ✅ Implement proper caching strategies
|
||||
|
||||
### Type Safety
|
||||
- ✅ Use TypeScript throughout
|
||||
- ✅ Define proper interfaces for props
|
||||
- ✅ Use type guards for runtime validation
|
||||
- ✅ Leverage shared type packages
|
||||
|
||||
### Testing
|
||||
- ✅ Write tests for critical functionality
|
||||
- ✅ Mock external dependencies properly
|
||||
- ✅ Test error scenarios and edge cases
|
||||
- ✅ Maintain good test coverage
|
||||
429
.cursor/rules/infrastructure.mdc
Normal file
@@ -0,0 +1,429 @@
|
||||
---
|
||||
description: Infrastructure, Terraform, Kubernetes Cluster related
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Formbricks Infrastructure Comprehensive Guide
|
||||
|
||||
## Infrastructure Overview
|
||||
|
||||
Formbricks uses a modern, cloud-native infrastructure built on AWS EKS with a focus on scalability, security, and operational excellence. The infrastructure follows Infrastructure as Code (IaC) principles using Terraform and GitOps patterns with Helm. The system has been specifically optimized to minimize ELB 502/504 errors through careful configuration of connection handling, health checks, and pod lifecycle management.
|
||||
|
||||
## Repository Structure & Organization
|
||||
|
||||
### Terraform File Organization
|
||||
```
|
||||
infra/terraform/
|
||||
├── main.tf # Core infrastructure (VPC, EKS, Karpenter)
|
||||
├── cloudwatch.tf # Monitoring, alerting, and CloudWatch alarms
|
||||
├── rds.tf # Aurora PostgreSQL database configuration
|
||||
├── elasticache.tf # Redis/Valkey caching layer
|
||||
├── observability.tf # Loki, Grafana, and monitoring stack
|
||||
├── iam.tf # GitHub OIDC, security roles
|
||||
├── secrets.tf # AWS Secrets Manager integration
|
||||
├── provider.tf # AWS, Kubernetes, Helm providers
|
||||
├── versions.tf # Provider version constraints
|
||||
└── data.tf # Data sources and external references
|
||||
```
|
||||
|
||||
### Helm Configuration
|
||||
- **Helmfile**: [infra/formbricks-cloud-helm/helmfile.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/helmfile.yaml.gotmpl) - Multi-environment orchestration
|
||||
- **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 with spot instances
|
||||
|
||||
### Key Infrastructure Files
|
||||
- **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
|
||||
|
||||
## Core Architecture Principles
|
||||
|
||||
### 1. Multi-Environment Strategy
|
||||
```hcl
|
||||
# Environment-aware resource creation
|
||||
locals {
|
||||
envs = {
|
||||
prod = "${local.project}-prod"
|
||||
stage = "${local.project}-stage"
|
||||
}
|
||||
}
|
||||
|
||||
# Resource duplication pattern
|
||||
resource "aws_secretsmanager_secret" "formbricks_app_secrets" {
|
||||
for_each = local.envs
|
||||
name = "${each.key}/formbricks/secrets"
|
||||
}
|
||||
```
|
||||
|
||||
**Key Patterns:**
|
||||
- **Environment isolation** through separate namespaces and resources
|
||||
- **Consistent naming** conventions across environments
|
||||
- **Resource sharing** where appropriate (VPC, EKS cluster)
|
||||
- **Environment-specific** configurations and scaling parameters
|
||||
|
||||
### 2. Network Architecture
|
||||
```hcl
|
||||
# Strategic subnet allocation for different workload types
|
||||
private_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 4, k)] # /20 - Application workloads
|
||||
public_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 48)] # /24 - Load balancers
|
||||
intra_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 52)] # /24 - EKS control plane
|
||||
database_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 56)] # /24 - RDS/ElastiCache
|
||||
```
|
||||
|
||||
**Design Principles:**
|
||||
- **Private EKS cluster** with no public endpoint access
|
||||
- **Multi-AZ deployment** across 3 availability zones
|
||||
- **VPC endpoints** for AWS services to reduce NAT costs
|
||||
- **Single NAT Gateway** for cost optimization
|
||||
|
||||
### 3. Security Model
|
||||
```hcl
|
||||
# IRSA (IAM Roles for Service Accounts) pattern
|
||||
module "formbricks_app_iam_role" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
|
||||
|
||||
oidc_providers = {
|
||||
eks = {
|
||||
provider_arn = module.eks.oidc_provider_arn
|
||||
namespace_service_accounts = ["formbricks:*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Security Best Practices:**
|
||||
- **GitHub OIDC** for CI/CD authentication (no long-lived credentials)
|
||||
- **Pod Identity** for workload AWS access
|
||||
- **AWS Secrets Manager** integration via External Secrets Operator
|
||||
- **Least privilege** IAM policies for all roles
|
||||
- **KMS encryption** for sensitive data at rest
|
||||
|
||||
## ALB Optimization & Error Reduction
|
||||
|
||||
### 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
|
||||
|
||||
### Expected Improvements
|
||||
- **60-80% reduction** in ELB 502 errors
|
||||
- **Faster recovery** during pod restarts
|
||||
- **Better connection reuse** efficiency
|
||||
- **Improved autoscaling** responsiveness
|
||||
|
||||
## Kubernetes Platform Configuration
|
||||
|
||||
### 1. EKS Cluster Setup
|
||||
```hcl
|
||||
# Modern EKS configuration
|
||||
cluster_version = "1.32"
|
||||
enable_cluster_creator_admin_permissions = false
|
||||
cluster_endpoint_public_access = false
|
||||
|
||||
cluster_addons = {
|
||||
coredns = { most_recent = true }
|
||||
eks-pod-identity-agent = { most_recent = true }
|
||||
aws-ebs-csi-driver = { most_recent = true }
|
||||
kube-proxy = { most_recent = true }
|
||||
vpc-cni = { most_recent = true }
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Karpenter Autoscaling & Node Management
|
||||
```hcl
|
||||
# Intelligent node provisioning
|
||||
requirements = [
|
||||
{
|
||||
key = "karpenter.k8s.aws/instance-family"
|
||||
operator = "In"
|
||||
values = ["c8g", "c7g", "m8g", "m7g", "r8g", "r7g"] # ARM64 Graviton
|
||||
},
|
||||
{
|
||||
key = "karpenter.k8s.aws/instance-cpu"
|
||||
operator = "In"
|
||||
values = ["2", "4", "8"] # Cost-optimized sizes
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Application Deployment Patterns
|
||||
|
||||
### 1. External Helm Chart Pattern
|
||||
```yaml
|
||||
# Helmfile configuration for external charts
|
||||
repositories:
|
||||
- name: helm-charts
|
||||
url: ghcr.io/formbricks/helm-charts
|
||||
oci: true
|
||||
|
||||
releases:
|
||||
- name: formbricks
|
||||
chart: helm-charts/formbricks
|
||||
version: ^3.0.0
|
||||
values: [values.yaml.gotmpl]
|
||||
```
|
||||
|
||||
**Advantages:**
|
||||
- **Separation of concerns** (infrastructure vs application)
|
||||
- **Version control** of application deployment
|
||||
- **Reusable charts** across environments
|
||||
- **OCI registry** for secure chart distribution
|
||||
|
||||
### 2. Configuration Management
|
||||
```yaml
|
||||
# External Secrets pattern
|
||||
externalSecret:
|
||||
enabled: true
|
||||
files:
|
||||
app-env:
|
||||
dataFrom:
|
||||
key: prod/formbricks/environment
|
||||
secretStore:
|
||||
kind: ClusterSecretStore
|
||||
name: aws-secrets-manager
|
||||
```
|
||||
|
||||
### 3. Environment-Specific Configurations
|
||||
- **Production**: On-demand instances, stricter resource limits
|
||||
- **Staging**: Spot instances, rate limiting disabled, relaxed resources
|
||||
|
||||
## Monitoring & Observability Stack
|
||||
|
||||
### 1. Critical ALB Metrics & CloudWatch Alarms
|
||||
```hcl
|
||||
# Comprehensive ALB monitoring
|
||||
alarms = {
|
||||
ALB_HTTPCode_ELB_502_Count = {
|
||||
alarm_description = "ALB 502 errors indicating backend connection issues"
|
||||
threshold = 20
|
||||
evaluation_periods = 3
|
||||
period = 300
|
||||
}
|
||||
ALB_HTTPCode_ELB_504_Count = {
|
||||
alarm_description = "ALB 504 timeout errors"
|
||||
threshold = 15
|
||||
evaluation_periods = 3
|
||||
period = 300
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Monitoring Thresholds:**
|
||||
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)
|
||||
|
||||
### 2. Log Aggregation & Analytics
|
||||
```hcl
|
||||
# Loki for centralized logging
|
||||
module "loki_s3_bucket" {
|
||||
source = "terraform-aws-modules/s3-bucket/aws"
|
||||
# S3 backend for long-term log storage
|
||||
}
|
||||
|
||||
module "observability_loki_iam_role" {
|
||||
# IRSA role for Loki to access S3
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Grafana Dashboards
|
||||
```hcl
|
||||
# Grafana with AWS CloudWatch integration
|
||||
policy = jsonencode({
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowReadingMetricsFromCloudWatch"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"cloudwatch:DescribeAlarms",
|
||||
"cloudwatch:ListMetrics",
|
||||
"cloudwatch:GetMetricData"
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## Cost Optimization Strategies
|
||||
|
||||
### 1. Instance & Compute Optimization
|
||||
- **ARM64 Graviton** processors (20% better price-performance)
|
||||
- **Spot instances** for staging environments
|
||||
- **Right-sizing** through Karpenter optimization
|
||||
- **Reserved capacity** for predictable production workloads
|
||||
|
||||
### 2. Network & Storage Optimization
|
||||
- **Single NAT Gateway** (vs. one per AZ)
|
||||
- **VPC endpoints** to reduce NAT traffic
|
||||
- **ELB cost optimization** through connection reuse
|
||||
- **GP3 storage** for better IOPS/cost ratio
|
||||
- **Lifecycle policies** for log retention
|
||||
|
||||
## Deployment Workflow & Best Practices
|
||||
|
||||
### 1. Infrastructure Updates
|
||||
```bash
|
||||
# Using the deployment script
|
||||
./infra/deploy-improvements.sh
|
||||
|
||||
# Manual process:
|
||||
cd infra/terraform
|
||||
terraform plan -out=changes.tfplan
|
||||
terraform apply changes.tfplan
|
||||
```
|
||||
|
||||
### 2. Application Updates
|
||||
```bash
|
||||
# Helmfile deployment
|
||||
cd infra/formbricks-cloud-helm
|
||||
helmfile sync
|
||||
|
||||
# Environment-specific deployment
|
||||
helmfile -e production sync
|
||||
helmfile -e staging sync
|
||||
```
|
||||
|
||||
### 3. Verification Steps
|
||||
1. **Infrastructure health**: Check EKS cluster status
|
||||
2. **Application readiness**: Verify pod status and health checks
|
||||
3. **Network connectivity**: Test ALB target group health
|
||||
4. **Monitoring**: Confirm CloudWatch metrics and alerts
|
||||
|
||||
### 4. Change Management Best Practices
|
||||
|
||||
**Testing Strategy:**
|
||||
- **Staging first**: Test all changes in staging environment with same configurations
|
||||
- **Gradual rollout**: Use blue-green or canary deployments
|
||||
- **Monitoring window**: Observe metrics for 24-48 hours after changes
|
||||
- **Rollback plan**: Always have a documented rollback strategy
|
||||
|
||||
**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
|
||||
- **Maintain ALB timeout alignment** across all layers
|
||||
|
||||
**Security Considerations:**
|
||||
- **Least privilege**: Review IAM permissions regularly
|
||||
- **Secret rotation**: Implement regular credential rotation
|
||||
- **Vulnerability scanning**: Keep base images updated
|
||||
- **Network policies**: Implement pod-to-pod communication controls
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### 1. ALB Error Investigation
|
||||
|
||||
**502 Error Analysis:**
|
||||
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
|
||||
|
||||
### 2. Infrastructure Issues
|
||||
|
||||
**Pod Startup Issues:**
|
||||
- Check **startup probes** and timing
|
||||
- Verify **resource requests** vs. available capacity
|
||||
- Review **image pull** policies and registry access
|
||||
- Monitor **Karpenter** node provisioning logs
|
||||
|
||||
**Connectivity Problems:**
|
||||
- Validate **security group** rules
|
||||
- Check **DNS resolution** within cluster
|
||||
- Verify **service mesh** configuration if applicable
|
||||
- Review **network policies** for pod communication
|
||||
|
||||
**Performance Degradation:**
|
||||
- Monitor **resource utilization** (CPU, memory, network)
|
||||
- Check **database connection** pooling and query performance
|
||||
- Review **cache hit ratios** for Redis/ElastiCache
|
||||
- Analyze **ALB metrics** for traffic patterns
|
||||
|
||||
### 3. 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
|
||||
|
||||
## Critical Considerations When Making Infrastructure Changes
|
||||
|
||||
1. **Always test in staging first** with identical configurations
|
||||
2. **Monitor ALB metrics** for 24-48 hours after changes
|
||||
3. **Use gradual rollouts** with proper health checks and canary deployments
|
||||
4. **Maintain timeout alignment** across ALB, application, and database layers
|
||||
5. **Verify security configurations** don't introduce vulnerabilities
|
||||
6. **Check cost impact** of infrastructure changes
|
||||
7. **Update monitoring and alerting** to cover new components
|
||||
8. **Document changes** and update runbooks accordingly
|
||||
|
||||
This comprehensive infrastructure provides a robust, scalable, and cost-effective platform for running Formbricks at scale while maintaining high availability, security standards, and minimal error rates.
|
||||
|
||||
5
.cursor/rules/performance-optimization.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
52
.cursor/rules/react-context-patterns.mdc
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# React Context & Provider Patterns
|
||||
|
||||
## Context Provider Best Practices
|
||||
|
||||
### Provider Implementation
|
||||
- Use TypeScript interfaces for provider props with optional `initialCount` for testing
|
||||
- Implement proper cleanup in `useEffect` to avoid React hooks warnings
|
||||
- Reference: [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx)
|
||||
|
||||
### Cleanup Pattern for Refs
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const currentPendingRequests = pendingRequests.current;
|
||||
const currentAbortController = abortController.current;
|
||||
|
||||
return () => {
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
currentPendingRequests.clear();
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Testing Context Providers
|
||||
- Always wrap components using context in the provider during tests
|
||||
- Use `initialCount` prop for predictable test scenarios
|
||||
- Mock context dependencies like `useParams`, `useResponseFilter`
|
||||
- Example from [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx):
|
||||
|
||||
```typescript
|
||||
render(
|
||||
<ResponseCountProvider survey={dummySurvey} initialCount={5}>
|
||||
<ComponentUnderTest />
|
||||
</ResponseCountProvider>
|
||||
);
|
||||
```
|
||||
|
||||
### Required Mocks for Context Testing
|
||||
- Mock `next/navigation` with `useParams` returning environment and survey IDs
|
||||
- Mock response filter context and actions
|
||||
- Mock API actions that the provider depends on
|
||||
|
||||
### Context Hook Usage
|
||||
- Create custom hooks like `useResponseCountContext()` for consuming context
|
||||
- Provide meaningful error messages when context is used outside provider
|
||||
- Use context for shared state that multiple components need to access
|
||||
5
.cursor/rules/react-context-providers.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
282
.cursor/rules/testing-patterns.mdc
Normal file
@@ -0,0 +1,282 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Testing Patterns & Best Practices
|
||||
|
||||
## Test File Naming & Environment
|
||||
|
||||
### File Extensions
|
||||
- Use `.test.tsx` for React component/hook tests (runs in jsdom environment)
|
||||
- Use `.test.ts` for utility/service tests (runs in Node environment)
|
||||
- The vitest config uses `environmentMatchGlobs` to automatically set jsdom for `.tsx` files
|
||||
|
||||
### Test Structure
|
||||
```typescript
|
||||
// Import the mocked functions first
|
||||
import { useHook } from "@/path/to/hook";
|
||||
import { serviceFunction } from "@/path/to/service";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/path/to/hook", () => ({
|
||||
useHook: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ComponentName", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Setup default mocks
|
||||
});
|
||||
|
||||
test("descriptive test name", async () => {
|
||||
// Test implementation
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## React Hook Testing
|
||||
|
||||
### Context Mocking
|
||||
When testing hooks that use React Context:
|
||||
```typescript
|
||||
vi.mocked(useResponseFilter).mockReturnValue({
|
||||
selectedFilter: {
|
||||
filter: [],
|
||||
onlyComplete: false,
|
||||
},
|
||||
setSelectedFilter: vi.fn(),
|
||||
selectedOptions: {
|
||||
questionOptions: [],
|
||||
questionFilterOptions: [],
|
||||
},
|
||||
setSelectedOptions: vi.fn(),
|
||||
dateRange: { from: new Date(), to: new Date() },
|
||||
setDateRange: vi.fn(),
|
||||
resetState: vi.fn(),
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Async Hooks
|
||||
- Always use `waitFor` for async operations
|
||||
- Test both loading and completed states
|
||||
- Verify API calls with correct parameters
|
||||
|
||||
```typescript
|
||||
test("fetches data on mount", async () => {
|
||||
const { result } = renderHook(() => useHook());
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toBe(expectedData);
|
||||
expect(vi.mocked(apiCall)).toHaveBeenCalledWith(expectedParams);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Hook Dependencies
|
||||
To test useEffect dependencies, ensure mocks return different values:
|
||||
```typescript
|
||||
// First render
|
||||
mockGetFormattedFilters.mockReturnValue(mockFilters);
|
||||
|
||||
// Change dependency and trigger re-render
|
||||
const newMockFilters = { ...mockFilters, finished: true };
|
||||
mockGetFormattedFilters.mockReturnValue(newMockFilters);
|
||||
rerender();
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
### Race Condition Testing
|
||||
Test AbortController implementation:
|
||||
```typescript
|
||||
test("cancels previous request when new request is made", async () => {
|
||||
let resolveFirst: (value: any) => void;
|
||||
let resolveSecond: (value: any) => void;
|
||||
|
||||
const firstPromise = new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
const secondPromise = new Promise((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(apiCall)
|
||||
.mockReturnValueOnce(firstPromise as any)
|
||||
.mockReturnValueOnce(secondPromise as any);
|
||||
|
||||
const { result } = renderHook(() => useHook());
|
||||
|
||||
// Trigger second request
|
||||
result.current.refetch();
|
||||
|
||||
// Resolve in order - first should be cancelled
|
||||
resolveFirst!({ data: 100 });
|
||||
resolveSecond!({ data: 200 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Should have result from second request
|
||||
expect(result.current.data).toBe(200);
|
||||
});
|
||||
```
|
||||
|
||||
### Cleanup Testing
|
||||
```typescript
|
||||
test("cleans up on unmount", () => {
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
|
||||
|
||||
const { unmount } = renderHook(() => useHook());
|
||||
unmount();
|
||||
|
||||
expect(abortSpy).toHaveBeenCalled();
|
||||
abortSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling Testing
|
||||
|
||||
### API Error Testing
|
||||
```typescript
|
||||
test("handles API errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.mocked(apiCall).mockRejectedValue(new Error("API Error"));
|
||||
|
||||
const { result } = renderHook(() => useHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error message:", expect.any(Error));
|
||||
expect(result.current.data).toBe(fallbackValue);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
### Cancelled Request Testing
|
||||
```typescript
|
||||
test("does not update state for cancelled requests", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
let rejectFirst: (error: any) => void;
|
||||
const firstPromise = new Promise((_, reject) => {
|
||||
rejectFirst = reject;
|
||||
});
|
||||
|
||||
vi.mocked(apiCall)
|
||||
.mockReturnValueOnce(firstPromise as any)
|
||||
.mockResolvedValueOnce({ data: 42 });
|
||||
|
||||
const { result } = renderHook(() => useHook());
|
||||
result.current.refetch();
|
||||
|
||||
const abortError = new Error("Request cancelled");
|
||||
rejectFirst!(abortError);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Should not log error for cancelled request
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
## Type Safety in Tests
|
||||
|
||||
### Mock Type Assertions
|
||||
Use type assertions for edge cases:
|
||||
```typescript
|
||||
vi.mocked(apiCall).mockResolvedValue({
|
||||
data: null as any, // For testing null handling
|
||||
});
|
||||
|
||||
vi.mocked(apiCall).mockResolvedValue({
|
||||
data: undefined as any, // For testing undefined handling
|
||||
});
|
||||
```
|
||||
|
||||
### Proper Mock Typing
|
||||
Ensure mocks match the actual interface:
|
||||
```typescript
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-123",
|
||||
name: "Test Survey",
|
||||
// ... other required properties
|
||||
} as unknown as TSurvey; // Use when partial mocking is needed
|
||||
```
|
||||
|
||||
## Common Test Patterns
|
||||
|
||||
### Testing State Changes
|
||||
```typescript
|
||||
test("updates state correctly", async () => {
|
||||
const { result } = renderHook(() => useHook());
|
||||
|
||||
// Initial state
|
||||
expect(result.current.value).toBe(initialValue);
|
||||
|
||||
// Trigger change
|
||||
result.current.updateValue(newValue);
|
||||
|
||||
// Verify change
|
||||
expect(result.current.value).toBe(newValue);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Multiple Scenarios
|
||||
```typescript
|
||||
test("handles different modes", async () => {
|
||||
// Test regular mode
|
||||
vi.mocked(useParams).mockReturnValue({ surveyId: "123" });
|
||||
const { rerender } = renderHook(() => useHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test sharing mode
|
||||
vi.mocked(useParams).mockReturnValue({
|
||||
surveyId: "123",
|
||||
sharingKey: "share-123"
|
||||
});
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(sharingApi)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
### Comprehensive Test Coverage
|
||||
For hooks, ensure you test:
|
||||
- ✅ Initialization (with/without initial values)
|
||||
- ✅ Data fetching (success/error cases)
|
||||
- ✅ State updates and refetching
|
||||
- ✅ Dependency changes triggering effects
|
||||
- ✅ Manual actions (refetch, reset)
|
||||
- ✅ Race condition prevention
|
||||
- ✅ Cleanup on unmount
|
||||
- ✅ Mode switching (if applicable)
|
||||
- ✅ Edge cases (null/undefined data)
|
||||
|
||||
### Test Naming
|
||||
Use descriptive test names that explain the scenario:
|
||||
- ✅ "initializes with initial count"
|
||||
- ✅ "fetches response count on mount for regular survey"
|
||||
- ✅ "cancels previous request when new request is made"
|
||||
- ❌ "test hook"
|
||||
- ❌ "it works"
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
checkUserExistsByEmail,
|
||||
getIsEmailUnique,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
@@ -10,13 +10,13 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
InvalidInputError,
|
||||
OperationNotAllowedError,
|
||||
TooManyRequestsError,
|
||||
} from "@formbricks/types/errors";
|
||||
@@ -37,10 +37,11 @@ export const updateUserAction = authenticatedActionClient
|
||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||
|
||||
let payload: TUserUpdateInput = {
|
||||
name: parsedInput.name,
|
||||
locale: parsedInput.locale,
|
||||
...(parsedInput.name && { name: parsedInput.name }),
|
||||
...(parsedInput.locale && { locale: parsedInput.locale }),
|
||||
};
|
||||
|
||||
// Only process email update if a new email is provided and it's different from current email
|
||||
if (inputEmail && ctx.user.email !== inputEmail) {
|
||||
// Check rate limit
|
||||
try {
|
||||
@@ -61,20 +62,26 @@ export const updateUserAction = authenticatedActionClient
|
||||
throw new AuthorizationError("Incorrect credentials");
|
||||
}
|
||||
|
||||
const doesUserExist = await checkUserExistsByEmail(inputEmail);
|
||||
// Check if the new email is unique, no user exists with the new email
|
||||
const isEmailUnique = await getIsEmailUnique(inputEmail);
|
||||
|
||||
if (doesUserExist) {
|
||||
throw new InvalidInputError("This email is already in use");
|
||||
}
|
||||
|
||||
if (EMAIL_VERIFICATION_DISABLED) {
|
||||
payload.email = inputEmail;
|
||||
} else {
|
||||
await sendVerificationNewEmail(ctx.user.id, inputEmail);
|
||||
// If the new email is unique, proceed with the email update
|
||||
if (isEmailUnique) {
|
||||
if (EMAIL_VERIFICATION_DISABLED) {
|
||||
payload.email = inputEmail;
|
||||
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
|
||||
} else {
|
||||
await sendVerificationNewEmail(ctx.user.id, inputEmail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await updateUser(ctx.user.id, payload);
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
if (Object.keys(payload).length > 0) {
|
||||
await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const ZUpdateAvatarAction = z.object({
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
@@ -21,11 +22,13 @@ import { useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { TUser, TUserUpdateInput, ZUser } from "@formbricks/types/user";
|
||||
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
||||
import { updateUserAction } from "../actions";
|
||||
|
||||
// Schema & types
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true });
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
|
||||
email: ZUserEmail.transform((val) => val?.trim().toLowerCase()),
|
||||
});
|
||||
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
|
||||
|
||||
export const EditProfileDetailsForm = ({
|
||||
@@ -80,9 +83,9 @@ export const EditProfileDetailsForm = ({
|
||||
|
||||
if (updatedUserResult?.data) {
|
||||
if (!emailVerificationDisabled) {
|
||||
toast.success(t("auth.verification-requested.verification_email_successfully_sent", { email }));
|
||||
toast.success(t("auth.verification-requested.new_email_verification_success"));
|
||||
} else {
|
||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||
toast.success(t("environments.settings.profile.email_change_initiated"));
|
||||
await signOut({ redirect: false });
|
||||
router.push(`/email-change-without-verification-success`);
|
||||
return;
|
||||
@@ -98,11 +101,6 @@ export const EditProfileDetailsForm = ({
|
||||
};
|
||||
|
||||
const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => {
|
||||
if (data.email !== user.email && data.email.toLowerCase() === user.email.toLowerCase()) {
|
||||
toast.error(t("auth.email-change.email_already_exists"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.email !== user.email) {
|
||||
setShowModal(true);
|
||||
} else {
|
||||
@@ -178,20 +176,24 @@ export const EditProfileDetailsForm = ({
|
||||
variant="ghost"
|
||||
className="h-10 w-full border border-slate-300 px-3 text-left">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{appLanguages.find((l) => l.code === field.value)?.label[field.value] ?? "NA"}
|
||||
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40 bg-slate-50 text-slate-700" align="start">
|
||||
{appLanguages.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.code}
|
||||
onClick={() => field.onChange(lang.code)}
|
||||
className="min-h-8 cursor-pointer">
|
||||
{lang.label[field.value]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuContent
|
||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
|
||||
align="start">
|
||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||
{appLanguages.map((lang) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={lang.code}
|
||||
value={lang.code}
|
||||
className="min-h-8 cursor-pointer">
|
||||
{lang.label["en-US"]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { checkUserExistsByEmail, verifyUserPassword } from "./user";
|
||||
import { getIsEmailUnique, verifyUserPassword } from "./user";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/user/cache", () => ({
|
||||
@@ -116,27 +116,27 @@ describe("User Library Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkUserExistsByEmail", () => {
|
||||
describe("getIsEmailUnique", () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
test("should return true if user exists", async () => {
|
||||
test("should return false if user exists", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
id: "some-user-id",
|
||||
} as any);
|
||||
|
||||
const result = await checkUserExistsByEmail(email);
|
||||
expect(result).toBe(true);
|
||||
const result = await getIsEmailUnique(email);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false if user does not exist", async () => {
|
||||
test("should return true if user does not exist", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await checkUserExistsByEmail(email);
|
||||
expect(result).toBe(false);
|
||||
const result = await getIsEmailUnique(email);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
|
||||
@@ -47,7 +47,7 @@ export const verifyUserPassword = async (userId: string, password: string): Prom
|
||||
return true;
|
||||
};
|
||||
|
||||
export const checkUserExistsByEmail = reactCache(
|
||||
export const getIsEmailUnique = reactCache(
|
||||
async (email: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
@@ -60,9 +60,9 @@ export const checkUserExistsByEmail = reactCache(
|
||||
},
|
||||
});
|
||||
|
||||
return !!user;
|
||||
return !user;
|
||||
},
|
||||
[`checkUserExistsByEmail-${email}`],
|
||||
[`getIsEmailUnique-${email}`],
|
||||
{
|
||||
tags: [userCache.tag.byEmail(email)],
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ export const getSurveySummaryAction = authenticatedActionClient
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return getSurveySummary(parsedInput.surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
@@ -52,7 +51,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterConte
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
|
||||
vi.mock("@/app/lib/surveys/surveys");
|
||||
vi.mock("@/app/share/[sharingKey]/actions");
|
||||
vi.mock("@/lib/utils/hooks/useIntervalWhenFocused");
|
||||
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
|
||||
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
|
||||
}));
|
||||
@@ -69,7 +67,6 @@ const mockUseResponseFilter = vi.mocked(useResponseFilter);
|
||||
const mockGetResponseCountAction = vi.mocked(getResponseCountAction);
|
||||
const mockRevalidateSurveyIdPath = vi.mocked(revalidateSurveyIdPath);
|
||||
const mockGetFormattedFilters = vi.mocked(getFormattedFilters);
|
||||
const mockUseIntervalWhenFocused = vi.mocked(useIntervalWhenFocused);
|
||||
const MockSecondaryNavigation = vi.mocked(SecondaryNavigation);
|
||||
|
||||
const mockSurveyLanguages: TSurveyLanguage[] = [
|
||||
@@ -120,7 +117,6 @@ const mockSurvey = {
|
||||
const defaultProps = {
|
||||
environmentId: "testEnvId",
|
||||
survey: mockSurvey,
|
||||
initialTotalResponseCount: 10,
|
||||
activeId: "summary",
|
||||
};
|
||||
|
||||
@@ -167,23 +163,20 @@ describe("SurveyAnalysisNavigation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("passes correct runWhen flag to useIntervalWhenFocused based on share embed modal", () => {
|
||||
test("renders navigation correctly for sharing page", () => {
|
||||
mockUsePathname.mockReturnValue(
|
||||
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
|
||||
);
|
||||
mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id });
|
||||
mockUseParams.mockReturnValue({ sharingKey: "test-sharing-key" });
|
||||
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
|
||||
mockGetFormattedFilters.mockReturnValue([] as any);
|
||||
mockGetResponseCountAction.mockResolvedValue({ data: 5 });
|
||||
|
||||
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue("true") } as any);
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} />);
|
||||
expect(mockUseIntervalWhenFocused).toHaveBeenCalledWith(expect.any(Function), 10000, false, false);
|
||||
cleanup();
|
||||
|
||||
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any);
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} />);
|
||||
expect(mockUseIntervalWhenFocused).toHaveBeenCalledWith(expect.any(Function), 10000, true, false);
|
||||
expect(MockSecondaryNavigation).toHaveBeenCalled();
|
||||
const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
|
||||
expect(lastCallArgs.navigation[0].href).toContain("/share/test-sharing-key");
|
||||
});
|
||||
|
||||
test("displays correct response count string in label for various scenarios", async () => {
|
||||
@@ -196,8 +189,8 @@ describe("SurveyAnalysisNavigation", () => {
|
||||
mockGetFormattedFilters.mockReturnValue([] as any);
|
||||
|
||||
// Scenario 1: total = 10, filtered = null (initial state)
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
|
||||
expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses (10)");
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} />);
|
||||
expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses");
|
||||
cleanup();
|
||||
vi.resetAllMocks(); // Reset mocks for next case
|
||||
|
||||
@@ -213,11 +206,11 @@ describe("SurveyAnalysisNavigation", () => {
|
||||
if (args && "filterCriteria" in args) return { data: 15, error: null, success: true };
|
||||
return { data: 15, error: null, success: true };
|
||||
});
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={15} />);
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
const lastCallArgs =
|
||||
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
|
||||
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
|
||||
expect(lastCallArgs.navigation[1].label).toBe("common.responses");
|
||||
});
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
@@ -234,11 +227,11 @@ describe("SurveyAnalysisNavigation", () => {
|
||||
if (args && "filterCriteria" in args) return { data: 15, error: null, success: true };
|
||||
return { data: 10, error: null, success: true };
|
||||
});
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
const lastCallArgs =
|
||||
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
|
||||
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
|
||||
expect(lastCallArgs.navigation[1].label).toBe("common.responses");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,105 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import {
|
||||
getResponseCountAction,
|
||||
revalidateSurveyIdPath,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getResponseCountBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
|
||||
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { InboxIcon, PresentationIcon } from "lucide-react";
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface SurveyAnalysisNavigationProps {
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
initialTotalResponseCount: number | null;
|
||||
activeId: string;
|
||||
}
|
||||
|
||||
export const SurveyAnalysisNavigation = ({
|
||||
environmentId,
|
||||
survey,
|
||||
initialTotalResponseCount,
|
||||
activeId,
|
||||
}: SurveyAnalysisNavigationProps) => {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslate();
|
||||
const params = useParams();
|
||||
const [filteredResponseCount, setFilteredResponseCount] = useState<number | null>(null);
|
||||
const [totalResponseCount, setTotalResponseCount] = useState<number | null>(initialTotalResponseCount);
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const isShareEmbedModalOpen = searchParams.get("share") === "true";
|
||||
|
||||
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${survey.id}`;
|
||||
const { selectedFilter, dateRange } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
[selectedFilter, dateRange, survey]
|
||||
);
|
||||
|
||||
const latestFiltersRef = useRef(filters);
|
||||
latestFiltersRef.current = filters;
|
||||
|
||||
const getResponseCount = () => {
|
||||
if (isSharingPage) return getResponseCountBySurveySharingKeyAction({ sharingKey });
|
||||
return getResponseCountAction({ surveyId: survey.id });
|
||||
};
|
||||
|
||||
const fetchResponseCount = async () => {
|
||||
const count = await getResponseCount();
|
||||
const responseCount = count?.data ?? 0;
|
||||
setTotalResponseCount(responseCount);
|
||||
};
|
||||
|
||||
const getFilteredResponseCount = useCallback(() => {
|
||||
if (isSharingPage)
|
||||
return getResponseCountBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
filterCriteria: latestFiltersRef.current,
|
||||
});
|
||||
return getResponseCountAction({ surveyId: survey.id, filterCriteria: latestFiltersRef.current });
|
||||
}, [isSharingPage, sharingKey, survey.id]);
|
||||
|
||||
const fetchFilteredResponseCount = useCallback(async () => {
|
||||
const count = await getFilteredResponseCount();
|
||||
const responseCount = count?.data ?? 0;
|
||||
setFilteredResponseCount(responseCount);
|
||||
}, [getFilteredResponseCount]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilteredResponseCount();
|
||||
}, [filters, isSharingPage, sharingKey, survey.id, fetchFilteredResponseCount]);
|
||||
|
||||
useIntervalWhenFocused(
|
||||
() => {
|
||||
fetchResponseCount();
|
||||
fetchFilteredResponseCount();
|
||||
},
|
||||
10000,
|
||||
!isShareEmbedModalOpen,
|
||||
false
|
||||
);
|
||||
|
||||
const getResponseCountString = () => {
|
||||
if (totalResponseCount === null) return "";
|
||||
if (filteredResponseCount === null) return `(${totalResponseCount})`;
|
||||
|
||||
const totalCount = Math.max(totalResponseCount, filteredResponseCount);
|
||||
|
||||
if (totalCount === filteredResponseCount) return `(${totalCount})`;
|
||||
|
||||
return `(${filteredResponseCount} of ${totalCount})`;
|
||||
};
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
@@ -114,7 +39,7 @@ export const SurveyAnalysisNavigation = ({
|
||||
},
|
||||
{
|
||||
id: "responses",
|
||||
label: `${t("common.responses")} ${getResponseCountString()}`,
|
||||
label: t("common.responses"),
|
||||
icon: <InboxIcon className="h-5 w-5" />,
|
||||
href: `${url}/responses?referer=true`,
|
||||
current: pathname?.includes("/responses"),
|
||||
|
||||
@@ -162,7 +162,6 @@ describe("ResponsePage", () => {
|
||||
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
|
||||
});
|
||||
expect(mockGetResponseCountAction).toHaveBeenCalled();
|
||||
expect(mockGetResponsesAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -179,7 +178,6 @@ describe("ResponsePage", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(mockGetResponseCountBySurveySharingKeyAction).toHaveBeenCalled();
|
||||
expect(mockGetResponsesBySurveySharingKeyAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -297,8 +295,7 @@ describe("ResponsePage", () => {
|
||||
rerender(<ResponsePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should fetch count and responses again due to filter change
|
||||
expect(mockGetResponseCountAction).toHaveBeenCalledTimes(2);
|
||||
// Should fetch responses again due to filter change
|
||||
expect(mockGetResponsesAction).toHaveBeenCalledTimes(2);
|
||||
// Check if it fetches with offset 0 (first page)
|
||||
expect(mockGetResponsesAction).toHaveBeenLastCalledWith(
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import {
|
||||
getResponseCountAction,
|
||||
getResponsesAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import {
|
||||
getResponseCountBySurveySharingKeyAction,
|
||||
getResponsesBySurveySharingKeyAction,
|
||||
} from "@/app/share/[sharingKey]/actions";
|
||||
import { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -49,7 +43,6 @@ export const ResponsePage = ({
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const [responseCount, setResponseCount] = useState<number | null>(null);
|
||||
const [responses, setResponses] = useState<TResponse[]>([]);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
@@ -97,9 +90,6 @@ export const ResponsePage = ({
|
||||
|
||||
const deleteResponses = (responseIds: string[]) => {
|
||||
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
|
||||
if (responseCount) {
|
||||
setResponseCount(responseCount - responseIds.length);
|
||||
}
|
||||
};
|
||||
|
||||
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
|
||||
@@ -118,29 +108,6 @@ export const ResponsePage = ({
|
||||
}
|
||||
}, [searchParams, resetState]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResponsesCount = async () => {
|
||||
let responseCount = 0;
|
||||
|
||||
if (isSharingPage) {
|
||||
const responseCountActionResponse = await getResponseCountBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
responseCount = responseCountActionResponse?.data || 0;
|
||||
} else {
|
||||
const responseCountActionResponse = await getResponseCountAction({
|
||||
surveyId,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
responseCount = responseCountActionResponse?.data || 0;
|
||||
}
|
||||
|
||||
setResponseCount(responseCount);
|
||||
};
|
||||
handleResponsesCount();
|
||||
}, [filters, isSharingPage, sharingKey, surveyId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInitialResponses = async () => {
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
|
||||
@@ -61,6 +62,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
RESPONSES_PER_PAGE: 10,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/getSurveyUrl", () => ({
|
||||
@@ -109,6 +111,14 @@ vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({
|
||||
environmentId: "test-env-id",
|
||||
surveyId: "test-survey-id",
|
||||
sharingKey: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockSurveyId = "test-survey-id";
|
||||
const mockUserId = "test-user-id";
|
||||
@@ -180,7 +190,7 @@ describe("ResponsesPage", () => {
|
||||
test("renders correctly with all data", async () => {
|
||||
const props = { params: mockParams };
|
||||
const jsx = await Page(props);
|
||||
render(jsx);
|
||||
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
|
||||
|
||||
await screen.findByTestId("page-content-wrapper");
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
@@ -196,7 +206,6 @@ describe("ResponsesPage", () => {
|
||||
isReadOnly: false,
|
||||
user: mockUser,
|
||||
surveyDomain: mockSurveyDomain,
|
||||
responseCount: 10,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
@@ -206,7 +215,6 @@ describe("ResponsesPage", () => {
|
||||
environmentId: mockEnvironmentId,
|
||||
survey: mockSurvey,
|
||||
activeId: "responses",
|
||||
initialTotalResponseCount: 10,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
@@ -33,7 +33,8 @@ const Page = async (props) => {
|
||||
|
||||
const tags = await getTagsByEnvironmentId(params.environmentId);
|
||||
|
||||
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const surveyDomain = getSurveyDomain();
|
||||
@@ -49,15 +50,10 @@ const Page = async (props) => {
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
surveyDomain={surveyDomain}
|
||||
responseCount={totalResponseCount}
|
||||
responseCount={responseCount}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
activeId="responses"
|
||||
initialTotalResponseCount={totalResponseCount}
|
||||
/>
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
|
||||
</PageHeader>
|
||||
<ResponsePage
|
||||
environment={environment}
|
||||
|
||||
@@ -38,18 +38,10 @@ interface SummaryListProps {
|
||||
responseCount: number | null;
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
totalResponseCount: number;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const SummaryList = ({
|
||||
summary,
|
||||
environment,
|
||||
responseCount,
|
||||
survey,
|
||||
totalResponseCount,
|
||||
locale,
|
||||
}: SummaryListProps) => {
|
||||
export const SummaryList = ({ summary, environment, responseCount, survey, locale }: SummaryListProps) => {
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
const { t } = useTranslate();
|
||||
const setFilter = (
|
||||
@@ -115,11 +107,7 @@ export const SummaryList = ({
|
||||
type="response"
|
||||
environment={environment}
|
||||
noWidgetRequired={survey.type === "link"}
|
||||
emptyMessage={
|
||||
totalResponseCount === 0
|
||||
? undefined
|
||||
: t("environments.surveys.summary.no_response_matches_filter")
|
||||
}
|
||||
emptyMessage={t("environments.surveys.summary.no_responses_found")}
|
||||
/>
|
||||
) : (
|
||||
summary.map((questionSummary) => {
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import {
|
||||
getResponseCountAction,
|
||||
getSurveySummaryAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
|
||||
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import {
|
||||
getResponseCountBySurveySharingKeyAction,
|
||||
getSummaryBySurveySharingKeyAction,
|
||||
} from "@/app/share/[sharingKey]/actions";
|
||||
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
|
||||
import { getSummaryBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { SummaryList } from "./SummaryList";
|
||||
import { SummaryMetadata } from "./SummaryMetadata";
|
||||
|
||||
const initialSurveySummary: TSurveySummary = {
|
||||
const defaultSurveySummary: TSurveySummary = {
|
||||
meta: {
|
||||
completedPercentage: 0,
|
||||
completedResponses: 0,
|
||||
@@ -44,11 +37,9 @@ interface SummaryPageProps {
|
||||
survey: TSurvey;
|
||||
surveyId: string;
|
||||
webAppUrl: string;
|
||||
user?: TUser;
|
||||
totalResponseCount: number;
|
||||
documentsPerPage?: number;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
initialSurveySummary?: TSurveySummary;
|
||||
}
|
||||
|
||||
export const SummaryPage = ({
|
||||
@@ -56,98 +47,69 @@ export const SummaryPage = ({
|
||||
survey,
|
||||
surveyId,
|
||||
webAppUrl,
|
||||
totalResponseCount,
|
||||
locale,
|
||||
isReadOnly,
|
||||
initialSurveySummary,
|
||||
}: SummaryPageProps) => {
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const isShareEmbedModalOpen = searchParams.get("share") === "true";
|
||||
|
||||
const [responseCount, setResponseCount] = useState<number | null>(null);
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(initialSurveySummary);
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
||||
initialSurveySummary || defaultSurveySummary
|
||||
);
|
||||
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
||||
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
[selectedFilter, dateRange, survey]
|
||||
);
|
||||
// Only fetch data when filters change or when there's no initial data
|
||||
useEffect(() => {
|
||||
// If we have initial data and no filters are applied, don't fetch
|
||||
const hasNoFilters =
|
||||
(!selectedFilter ||
|
||||
Object.keys(selectedFilter).length === 0 ||
|
||||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
|
||||
(!dateRange || (!dateRange.from && !dateRange.to));
|
||||
|
||||
// Use a ref to keep the latest state and props
|
||||
const latestFiltersRef = useRef(filters);
|
||||
latestFiltersRef.current = filters;
|
||||
if (initialSurveySummary && hasNoFilters) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const getResponseCount = useCallback(() => {
|
||||
if (isSharingPage)
|
||||
return getResponseCountBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
filterCriteria: latestFiltersRef.current,
|
||||
});
|
||||
return getResponseCountAction({
|
||||
surveyId,
|
||||
filterCriteria: latestFiltersRef.current,
|
||||
});
|
||||
}, [isSharingPage, sharingKey, surveyId]);
|
||||
|
||||
const getSummary = useCallback(() => {
|
||||
if (isSharingPage)
|
||||
return getSummaryBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
filterCriteria: latestFiltersRef.current,
|
||||
});
|
||||
|
||||
return getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: latestFiltersRef.current,
|
||||
});
|
||||
}, [isSharingPage, sharingKey, surveyId]);
|
||||
|
||||
const handleInitialData = useCallback(
|
||||
async (isInitialLoad = false) => {
|
||||
if (isInitialLoad) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
const fetchSummary = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const [updatedResponseCountData, updatedSurveySummary] = await Promise.all([
|
||||
getResponseCount(),
|
||||
getSummary(),
|
||||
]);
|
||||
// Recalculate filters inside the effect to ensure we have the latest values
|
||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
let updatedSurveySummary;
|
||||
|
||||
const responseCount = updatedResponseCountData?.data ?? 0;
|
||||
const surveySummary = updatedSurveySummary?.data ?? initialSurveySummary;
|
||||
if (isSharingPage) {
|
||||
updatedSurveySummary = await getSummaryBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
} else {
|
||||
updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
}
|
||||
|
||||
setResponseCount(responseCount);
|
||||
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
|
||||
setSurveySummary(surveySummary);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[getResponseCount, getSummary]
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleInitialData(true);
|
||||
}, [filters, isSharingPage, sharingKey, surveyId, handleInitialData]);
|
||||
|
||||
useIntervalWhenFocused(
|
||||
() => {
|
||||
handleInitialData(false);
|
||||
},
|
||||
10000,
|
||||
!isShareEmbedModalOpen,
|
||||
false
|
||||
);
|
||||
fetchSummary();
|
||||
}, [selectedFilter, dateRange, survey.id, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
@@ -177,10 +139,9 @@ export const SummaryPage = ({
|
||||
<ScrollToTop containerId="mainContent" />
|
||||
<SummaryList
|
||||
summary={surveySummary.summary}
|
||||
responseCount={responseCount}
|
||||
responseCount={surveySummary.meta.totalResponses}
|
||||
survey={surveyMemoized}
|
||||
environment={environment}
|
||||
totalResponseCount={totalResponseCount}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
usePathname: () => "/current",
|
||||
useParams: () => ({ environmentId: "env123", surveyId: "survey123" }),
|
||||
}));
|
||||
|
||||
// Mock copySurveyLink to return a predictable string
|
||||
@@ -69,6 +70,23 @@ vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn((response) => response?.error || "Unknown error"),
|
||||
}));
|
||||
|
||||
// Mock ResponseCountProvider dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||
useResponseFilter: vi.fn(() => ({ selectedFilter: "all", dateRange: {} })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions", () => ({
|
||||
getResponseCountAction: vi.fn(() => Promise.resolve({ data: 5 })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||
getFormattedFilters: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getResponseCountBySurveySharingKeyAction: vi.fn(() => Promise.resolve({ data: 5 })),
|
||||
}));
|
||||
|
||||
vi.spyOn(toast, "success");
|
||||
vi.spyOn(toast, "error");
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ export const SurveyAnalysisCTA = ({
|
||||
icon: SquarePenIcon,
|
||||
tooltip: t("common.edit"),
|
||||
onClick: () => {
|
||||
responseCount && responseCount > 0
|
||||
responseCount > 0
|
||||
? setIsCautionDialogOpen(true)
|
||||
: router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`);
|
||||
},
|
||||
|
||||
@@ -758,7 +758,6 @@ describe("getSurveySummary", () => {
|
||||
expect(summary.dropOff).toBeDefined();
|
||||
expect(summary.summary).toBeDefined();
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, undefined);
|
||||
expect(prisma.response.findMany).toHaveBeenCalled(); // Check if getResponsesForSummary was effectively called
|
||||
expect(getDisplayCountBySurveyId).toHaveBeenCalled();
|
||||
});
|
||||
@@ -770,7 +769,6 @@ describe("getSurveySummary", () => {
|
||||
|
||||
test("handles filterCriteria", async () => {
|
||||
const filterCriteria: TResponseFilterCriteria = { finished: true };
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(2); // Assume 2 finished responses
|
||||
const finishedResponses = mockResponses
|
||||
.filter((r) => r.finished)
|
||||
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
|
||||
@@ -778,7 +776,6 @@ describe("getSurveySummary", () => {
|
||||
|
||||
await getSurveySummary(mockSurveyId, filterCriteria);
|
||||
|
||||
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, filterCriteria);
|
||||
expect(prisma.response.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ surveyId: mockSurveyId }), // buildWhereClause is mocked
|
||||
|
||||
@@ -5,7 +5,6 @@ import { displayCache } from "@/lib/display/cache";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { buildWhereClause } from "@/lib/response/utils";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -13,6 +12,7 @@ import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -917,22 +917,24 @@ export const getSurveySummary = reactCache(
|
||||
}
|
||||
|
||||
const batchSize = 5000;
|
||||
const responseCount = await getResponseCountBySurveyId(surveyId, filterCriteria);
|
||||
|
||||
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
|
||||
|
||||
const pages = Math.ceil(responseCount / batchSize);
|
||||
// Use cursor-based pagination instead of count + offset to avoid expensive queries
|
||||
const responses: TSurveySummaryResponse[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
let hasMore = true;
|
||||
|
||||
// Create an array of batch fetch promises
|
||||
const batchPromises = Array.from({ length: pages }, (_, i) =>
|
||||
getResponsesForSummary(surveyId, batchSize, i * batchSize, filterCriteria)
|
||||
);
|
||||
while (hasMore) {
|
||||
const batch = await getResponsesForSummary(surveyId, batchSize, 0, filterCriteria, cursor);
|
||||
responses.push(...batch);
|
||||
|
||||
// Fetch all batches in parallel
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
|
||||
// Combine all batch results
|
||||
const responses = batchResults.flat();
|
||||
if (batch.length < batchSize) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
// Use the last response's ID as cursor for next batch
|
||||
cursor = batch[batch.length - 1].id;
|
||||
}
|
||||
}
|
||||
|
||||
const responseIds = hasFilter ? responses.map((response) => response.id) : [];
|
||||
|
||||
@@ -972,7 +974,8 @@ export const getResponsesForSummary = reactCache(
|
||||
surveyId: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
filterCriteria?: TResponseFilterCriteria
|
||||
filterCriteria?: TResponseFilterCriteria,
|
||||
cursor?: string
|
||||
): Promise<TSurveySummaryResponse[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
@@ -980,18 +983,28 @@ export const getResponsesForSummary = reactCache(
|
||||
[surveyId, ZId],
|
||||
[limit, ZOptionalNumber],
|
||||
[offset, ZOptionalNumber],
|
||||
[filterCriteria, ZResponseFilterCriteria.optional()]
|
||||
[filterCriteria, ZResponseFilterCriteria.optional()],
|
||||
[cursor, z.string().cuid2().optional()]
|
||||
);
|
||||
|
||||
const queryLimit = limit ?? RESPONSES_PER_PAGE;
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) return [];
|
||||
try {
|
||||
const whereClause: Prisma.ResponseWhereInput = {
|
||||
surveyId,
|
||||
...buildWhereClause(survey, filterCriteria),
|
||||
};
|
||||
|
||||
// Add cursor condition for cursor-based pagination
|
||||
if (cursor) {
|
||||
whereClause.id = {
|
||||
lt: cursor, // Get responses with ID less than cursor (for desc order)
|
||||
};
|
||||
}
|
||||
|
||||
const responses = await prisma.response.findMany({
|
||||
where: {
|
||||
surveyId,
|
||||
...buildWhereClause(survey, filterCriteria),
|
||||
},
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
data: true,
|
||||
@@ -1013,6 +1026,9 @@ export const getResponsesForSummary = reactCache(
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
{
|
||||
id: "desc", // Secondary sort by ID for consistent pagination
|
||||
},
|
||||
],
|
||||
take: queryLimit,
|
||||
skip: offset,
|
||||
@@ -1043,7 +1059,9 @@ export const getResponsesForSummary = reactCache(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
|
||||
[
|
||||
`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}-${cursor || ""}`,
|
||||
],
|
||||
{
|
||||
tags: [responseCache.tag.bySurveyId(surveyId)],
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import SurveyPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page";
|
||||
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -38,7 +40,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
RESPONSES_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
@@ -78,6 +80,13 @@ vi.mock("@/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary",
|
||||
() => ({
|
||||
getSurveySummary: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
@@ -100,6 +109,11 @@ vi.mock("@/tolgee/server", () => ({
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: vi.fn(),
|
||||
useParams: () => ({
|
||||
environmentId: "test-environment-id",
|
||||
surveyId: "test-survey-id",
|
||||
sharingKey: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-environment-id";
|
||||
@@ -172,6 +186,21 @@ const mockSession = {
|
||||
expires: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour from now
|
||||
} as any;
|
||||
|
||||
const mockSurveySummary = {
|
||||
meta: {
|
||||
completedPercentage: 75,
|
||||
completedResponses: 15,
|
||||
displayCount: 20,
|
||||
dropOffPercentage: 25,
|
||||
dropOffCount: 5,
|
||||
startsPercentage: 80,
|
||||
totalResponses: 20,
|
||||
ttcAverage: 120,
|
||||
},
|
||||
dropOff: [],
|
||||
summary: [],
|
||||
};
|
||||
|
||||
describe("SurveyPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
@@ -183,6 +212,7 @@ describe("SurveyPage", () => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||
vi.mocked(getSurveyDomain).mockReturnValue("test.domain.com");
|
||||
vi.mocked(getSurveySummary).mockResolvedValue(mockSurveySummary);
|
||||
vi.mocked(notFound).mockClear();
|
||||
});
|
||||
|
||||
@@ -193,7 +223,8 @@ describe("SurveyPage", () => {
|
||||
|
||||
test("renders correctly with valid data", async () => {
|
||||
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
|
||||
render(await SurveyPage({ params }));
|
||||
const jsx = await SurveyPage({ params });
|
||||
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
|
||||
|
||||
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
@@ -204,7 +235,6 @@ describe("SurveyPage", () => {
|
||||
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(vi.mocked(getUser)).toHaveBeenCalledWith(mockUserId);
|
||||
expect(vi.mocked(getResponseCountBySurveyId)).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(vi.mocked(getSurveyDomain)).toHaveBeenCalled();
|
||||
|
||||
expect(vi.mocked(SurveyAnalysisNavigation).mock.calls[0][0]).toEqual(
|
||||
@@ -212,7 +242,6 @@ describe("SurveyPage", () => {
|
||||
environmentId: mockEnvironmentId,
|
||||
survey: mockSurvey,
|
||||
activeId: "summary",
|
||||
initialTotalResponseCount: 10,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -222,18 +251,17 @@ describe("SurveyPage", () => {
|
||||
survey: mockSurvey,
|
||||
surveyId: mockSurveyId,
|
||||
webAppUrl: WEBAPP_URL,
|
||||
user: mockUser,
|
||||
totalResponseCount: 10,
|
||||
documentsPerPage: DOCUMENTS_PER_PAGE,
|
||||
isReadOnly: false,
|
||||
locale: mockUser.locale ?? DEFAULT_LOCALE,
|
||||
initialSurveySummary: mockSurveySummary,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("calls notFound if surveyId is not present in params", async () => {
|
||||
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: undefined }) as any;
|
||||
render(await SurveyPage({ params }));
|
||||
const jsx = await SurveyPage({ params });
|
||||
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
|
||||
expect(vi.mocked(notFound)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -243,7 +271,7 @@ describe("SurveyPage", () => {
|
||||
try {
|
||||
// We need to await the component itself because it's an async component
|
||||
const SurveyPageComponent = await SurveyPage({ params });
|
||||
render(SurveyPageComponent);
|
||||
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
|
||||
} catch (e: any) {
|
||||
expect(e.message).toBe("common.survey_not_found");
|
||||
}
|
||||
@@ -256,7 +284,7 @@ describe("SurveyPage", () => {
|
||||
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
|
||||
try {
|
||||
const SurveyPageComponent = await SurveyPage({ params });
|
||||
render(SurveyPageComponent);
|
||||
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
|
||||
} catch (e: any) {
|
||||
expect(e.message).toBe("common.user_not_found");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -37,10 +37,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
|
||||
// I took this out cause it's cloud only right?
|
||||
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
|
||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||
|
||||
const surveyDomain = getSurveyDomain();
|
||||
|
||||
@@ -55,26 +53,19 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
surveyDomain={surveyDomain}
|
||||
responseCount={totalResponseCount}
|
||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
activeId="summary"
|
||||
initialTotalResponseCount={totalResponseCount}
|
||||
/>
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
|
||||
</PageHeader>
|
||||
<SummaryPage
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={params.surveyId}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
user={user}
|
||||
totalResponseCount={totalResponseCount}
|
||||
documentsPerPage={DOCUMENTS_PER_PAGE}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={user.locale ?? DEFAULT_LOCALE}
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
/>
|
||||
|
||||
<SettingsId title={t("common.survey_id")} id={surveyId}></SettingsId>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
@@ -59,7 +59,6 @@ describe("endpoint-validator", () => {
|
||||
|
||||
describe("isClientSideApiRoute", () => {
|
||||
test("should return true for client-side API routes", () => {
|
||||
expect(isClientSideApiRoute("/api/packages/something")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/js/actions")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/storage")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/other")).toBe(true);
|
||||
|
||||
@@ -8,7 +8,6 @@ export const isVerifyEmailRoute = (url: string) => url === "/auth/verify-email";
|
||||
export const isForgotPasswordRoute = (url: string) => url === "/auth/forgot-password";
|
||||
|
||||
export const isClientSideApiRoute = (url: string): boolean => {
|
||||
if (url.includes("/api/packages/")) return true;
|
||||
if (url.includes("/api/v1/js/actions")) return true;
|
||||
if (url.includes("/api/v1/client/storage")) return true;
|
||||
const regex = /^\/api\/v\d+\/client\//;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[
|
||||
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
@@ -46,19 +45,13 @@ const Page = async (props: ResponsesPageProps) => {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const totalResponseCount = await getResponseCountBySurveyId(surveyId);
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<PageContentWrapper className="w-full">
|
||||
<PageHeader pageTitle={survey.name}>
|
||||
<SurveyAnalysisNavigation
|
||||
survey={survey}
|
||||
environmentId={environment.id}
|
||||
activeId="responses"
|
||||
initialTotalResponseCount={totalResponseCount}
|
||||
/>
|
||||
<SurveyAnalysisNavigation survey={survey} environmentId={environment.id} activeId="responses" />
|
||||
</PageHeader>
|
||||
<ResponsePage
|
||||
environment={environment}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -47,27 +47,23 @@ const Page = async (props: SummaryPageProps) => {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const totalResponseCount = await getResponseCountBySurveyId(surveyId);
|
||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<PageContentWrapper className="w-full">
|
||||
<PageHeader pageTitle={survey.name}>
|
||||
<SurveyAnalysisNavigation
|
||||
survey={survey}
|
||||
environmentId={environment.id}
|
||||
activeId="summary"
|
||||
initialTotalResponseCount={totalResponseCount}
|
||||
/>
|
||||
<SurveyAnalysisNavigation survey={survey} environmentId={environment.id} activeId="summary" />
|
||||
</PageHeader>
|
||||
<SummaryPage
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={survey.id}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
totalResponseCount={totalResponseCount}
|
||||
isReadOnly={true}
|
||||
locale={DEFAULT_LOCALE}
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const getSummaryBySurveySharingKeyAction = actionClient
|
||||
const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey);
|
||||
if (!surveyId) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await getSurveySummary(surveyId, parsedInput.filterCriteria);
|
||||
return getSurveySummary(surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
const ZGetResponseCountBySurveySharingKeyAction = z.object({
|
||||
@@ -57,7 +57,7 @@ export const getResponseCountBySurveySharingKeyAction = actionClient
|
||||
const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey);
|
||||
if (!surveyId) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await getResponseCountBySurveyId(surveyId, parsedInput.filterCriteria);
|
||||
return getResponseCountBySurveyId(surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
const ZGetSurveyFilterDataBySurveySharingKeyAction = z.object({
|
||||
|
||||
@@ -11,6 +11,8 @@ const { PHASE_PRODUCTION_BUILD } = require("next/constants");
|
||||
|
||||
// @fortedigital/nextjs-cache-handler dependencies
|
||||
const createRedisHandler = require("@fortedigital/nextjs-cache-handler/redis-strings").default;
|
||||
const createBufferStringHandler =
|
||||
require("@fortedigital/nextjs-cache-handler/buffer-string-decorator").default;
|
||||
const { Next15CacheHandler } = require("@fortedigital/nextjs-cache-handler/next-15-cache-handler");
|
||||
|
||||
// Usual onCreation from @neshca/cache-handler
|
||||
@@ -85,7 +87,7 @@ CacheHandler.onCreation(() => {
|
||||
global.cacheHandlerConfigPromise = null;
|
||||
|
||||
global.cacheHandlerConfig = {
|
||||
handlers: [redisCacheHandler],
|
||||
handlers: [createBufferStringHandler(redisCacheHandler)],
|
||||
};
|
||||
|
||||
return global.cacheHandlerConfig;
|
||||
|
||||
@@ -95,8 +95,6 @@ export const ITEMS_PER_PAGE = 30;
|
||||
export const SURVEYS_PER_PAGE = 12;
|
||||
export const RESPONSES_PER_PAGE = 25;
|
||||
export const TEXT_RESPONSES_PER_PAGE = 5;
|
||||
export const INSIGHTS_PER_PAGE = 10;
|
||||
export const DOCUMENTS_PER_PAGE = 10;
|
||||
export const MAX_RESPONSES_FOR_INSIGHT_GENERATION = 500;
|
||||
export const MAX_OTHER_OPTION_LENGTH = 250;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
@@ -98,7 +99,7 @@ export const getResponseContact = (
|
||||
if (!responsePrisma.contact) return null;
|
||||
|
||||
return {
|
||||
id: responsePrisma.contact.id as string,
|
||||
id: responsePrisma.contact.id,
|
||||
userId: responsePrisma.contact.attributes.find((attribute) => attribute.attributeKey.key === "userId")
|
||||
?.value as string,
|
||||
};
|
||||
@@ -291,7 +292,8 @@ export const getResponses = reactCache(
|
||||
surveyId: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
filterCriteria?: TResponseFilterCriteria
|
||||
filterCriteria?: TResponseFilterCriteria,
|
||||
cursor?: string
|
||||
): Promise<TResponse[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
@@ -299,26 +301,39 @@ export const getResponses = reactCache(
|
||||
[surveyId, ZId],
|
||||
[limit, ZOptionalNumber],
|
||||
[offset, ZOptionalNumber],
|
||||
[filterCriteria, ZResponseFilterCriteria.optional()]
|
||||
[filterCriteria, ZResponseFilterCriteria.optional()],
|
||||
[cursor, z.string().cuid2().optional()]
|
||||
);
|
||||
|
||||
limit = limit ?? RESPONSES_PER_PAGE;
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) return [];
|
||||
try {
|
||||
const whereClause: Prisma.ResponseWhereInput = {
|
||||
surveyId,
|
||||
...buildWhereClause(survey, filterCriteria),
|
||||
};
|
||||
|
||||
// Add cursor condition for cursor-based pagination
|
||||
if (cursor) {
|
||||
whereClause.id = {
|
||||
lt: cursor, // Get responses with ID less than cursor (for desc order)
|
||||
};
|
||||
}
|
||||
|
||||
const responses = await prisma.response.findMany({
|
||||
where: {
|
||||
surveyId,
|
||||
...buildWhereClause(survey, filterCriteria),
|
||||
},
|
||||
where: whereClause,
|
||||
select: responseSelection,
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
{
|
||||
id: "desc", // Secondary sort by ID for consistent pagination
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const transformedResponses: TResponse[] = await Promise.all(
|
||||
@@ -340,7 +355,7 @@ export const getResponses = reactCache(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getResponses-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
|
||||
[`getResponses-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}-${cursor}`],
|
||||
{
|
||||
tags: [responseCache.tag.bySurveyId(surveyId)],
|
||||
}
|
||||
@@ -360,19 +375,27 @@ export const getResponseDownloadUrl = async (
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
const environmentId = survey.environmentId as string;
|
||||
const environmentId = survey.environmentId;
|
||||
|
||||
const accessType = "private";
|
||||
const batchSize = 3000;
|
||||
const responseCount = await getResponseCountBySurveyId(surveyId, filterCriteria);
|
||||
const pages = Math.ceil(responseCount / batchSize);
|
||||
|
||||
const responsesArray = await Promise.all(
|
||||
Array.from({ length: pages }, (_, i) => {
|
||||
return getResponses(surveyId, batchSize, i * batchSize, filterCriteria);
|
||||
})
|
||||
);
|
||||
const responses = responsesArray.flat();
|
||||
// Use cursor-based pagination instead of count + offset to avoid expensive queries
|
||||
const responses: TResponse[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const batch = await getResponses(surveyId, batchSize, 0, filterCriteria, cursor);
|
||||
responses.push(...batch);
|
||||
|
||||
if (batch.length < batchSize) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
// Use the last response's ID as cursor for next batch
|
||||
cursor = batch[batch.length - 1].id;
|
||||
}
|
||||
}
|
||||
|
||||
const { metaDataFields, questions, hiddenFields, variables, userAttributes } = extractSurveyDetails(
|
||||
survey,
|
||||
@@ -442,8 +465,8 @@ export const getResponsesByEnvironmentId = reactCache(
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const transformedResponses: TResponse[] = await Promise.all(
|
||||
@@ -478,8 +501,6 @@ export const updateResponse = async (
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseId, ZId], [responseInput, ZResponseUpdateInput]);
|
||||
try {
|
||||
// const currentResponse = await getResponse(responseId);
|
||||
|
||||
// use direct prisma call to avoid cache issues
|
||||
const currentResponse = await prisma.response.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -238,14 +238,14 @@ describe("Tests for getResponseDownloadUrl service", () => {
|
||||
expect(fileExtension).not.toEqual("xlsx");
|
||||
});
|
||||
|
||||
test("Throws DatabaseError on PrismaClientKnownRequestError, when the getResponseCountBySurveyId fails", async () => {
|
||||
test("Throws DatabaseError on PrismaClientKnownRequestError, when the getResponses fails", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
|
||||
prisma.response.count.mockRejectedValue(errToThrow);
|
||||
prisma.response.findMany.mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getResponseDownloadUrl(mockSurveyId, "csv")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export const useIntervalWhenFocused = (
|
||||
callback: () => void,
|
||||
intervalDuration: number,
|
||||
isActive: boolean,
|
||||
shouldExecuteImmediately = true
|
||||
) => {
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (isActive) {
|
||||
if (shouldExecuteImmediately) {
|
||||
// Execute the callback immediately when the tab comes into focus
|
||||
callback();
|
||||
}
|
||||
|
||||
// Set the interval to execute the callback every `intervalDuration` milliseconds
|
||||
intervalRef.current = setInterval(() => {
|
||||
callback();
|
||||
}, intervalDuration);
|
||||
}
|
||||
}, [isActive, intervalDuration, callback, shouldExecuteImmediately]);
|
||||
|
||||
const handleBlur = () => {
|
||||
// Clear the interval when the tab loses focus
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Attach focus and blur event listeners
|
||||
window.addEventListener("focus", handleFocus);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
|
||||
// Handle initial focus
|
||||
handleFocus();
|
||||
|
||||
// Cleanup interval and event listeners when the component unmounts or dependencies change
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, [isActive, intervalDuration, handleFocus]);
|
||||
};
|
||||
|
||||
export default useIntervalWhenFocused;
|
||||
@@ -9,10 +9,11 @@
|
||||
"continue_with_saml": "Login mit SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Bitte bestätige dein Passwort, bevor du deine E-Mail-Adresse änderst",
|
||||
"email_already_exists": "Diese E-Mail wird bereits verwendet",
|
||||
"email_change_success": "E-Mail erfolgreich geändert",
|
||||
"email_change_success_description": "Du hast deine E-Mail-Adresse erfolgreich geändert. Bitte logge dich mit deiner neuen E-Mail-Adresse ein.",
|
||||
"email_verification_failed": "E-Mail-Bestätigung fehlgeschlagen",
|
||||
"email_verification_loading": "E-Mail-Bestätigung läuft...",
|
||||
"email_verification_loading_description": "Wir aktualisieren Ihre E-Mail-Adresse in unserem System. Dies kann einige Sekunden dauern.",
|
||||
"invalid_or_expired_token": "E-Mail-Änderung fehlgeschlagen. Dein Token ist ungültig oder abgelaufen.",
|
||||
"new_email": "Neue E-Mail",
|
||||
"old_email": "Alte E-Mail"
|
||||
@@ -88,11 +89,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Ungültige E-Mail-Adresse",
|
||||
"invalid_token": "Ungültiges Token ☹️",
|
||||
"new_email_verification_success": "Wenn die Adresse gültig ist, wurde eine Bestätigungs-E-Mail gesendet.",
|
||||
"no_email_provided": "Keine E-Mail bereitgestellt",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Bitte klicke auf den Link in der E-Mail, um dein Konto zu aktivieren.",
|
||||
"please_confirm_your_email_address": "Bitte bestätige deine E-Mail-Adresse",
|
||||
"resend_verification_email": "Bestätigungs-E-Mail erneut senden",
|
||||
"verification_email_successfully_sent": "Bestätigungs-E-Mail an {email} gesendet. Bitte überprüfen Sie, um das Update abzuschließen.",
|
||||
"verification_email_resent_successfully": "Bestätigungs-E-Mail gesendet! Bitte überprüfe dein Postfach.",
|
||||
"we_sent_an_email_to": "Wir haben eine E-Mail an {email} gesendet",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Hast Du keine E-Mail erhalten oder ist dein Link abgelaufen?"
|
||||
},
|
||||
@@ -1149,6 +1151,7 @@
|
||||
"disable_two_factor_authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"disable_two_factor_authentication_description": "Wenn Du die Zwei-Faktor-Authentifizierung deaktivieren musst, empfehlen wir, sie so schnell wie möglich wieder zu aktivieren.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugang ohne deinen Authenticator zu gewähren.",
|
||||
"email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.",
|
||||
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
|
||||
"file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.",
|
||||
@@ -1766,7 +1769,7 @@
|
||||
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
|
||||
"make_sure_the_survey_type_is_set_to": "Stelle sicher, dass der Umfragetyp richtig eingestellt ist",
|
||||
"mobile_app": "Mobile App",
|
||||
"no_response_matches_filter": "Keine Antwort entspricht deinem Filter",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"only_completed": "Nur vollständige Antworten",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"overall": "Insgesamt",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"continue_with_saml": "Continue with SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Please confirm your password before changing your email address",
|
||||
"email_already_exists": "This email is already in use",
|
||||
"email_change_success": "Email changed successfully",
|
||||
"email_change_success_description": "You have successfully changed your email address. Please log in with your new email address.",
|
||||
"email_verification_failed": "Email verification failed",
|
||||
"email_verification_loading": "Email verification in progress...",
|
||||
"email_verification_loading_description": "We are updating your email address in our system. This may take a few seconds.",
|
||||
"invalid_or_expired_token": "Email change failed. Your token is invalid or expired.",
|
||||
"new_email": "New Email",
|
||||
"old_email": "Old Email"
|
||||
@@ -88,11 +89,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Invalid email address",
|
||||
"invalid_token": "Invalid token ☹️",
|
||||
"new_email_verification_success": "If the address is valid, a verification email has been sent.",
|
||||
"no_email_provided": "No email provided",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Please click the link in the email to activate your account.",
|
||||
"please_confirm_your_email_address": "Please confirm your email address",
|
||||
"resend_verification_email": "Resend verification email",
|
||||
"verification_email_successfully_sent": "Verification email sent to {email}. Please verify to complete the update.",
|
||||
"verification_email_resent_successfully": "Verification email sent! Please check your inbox.",
|
||||
"we_sent_an_email_to": "We sent an email to {email}. ",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "You didn't receive an email or your link expired?"
|
||||
},
|
||||
@@ -984,7 +986,7 @@
|
||||
"2000_monthly_identified_users": "2000 Monthly Identified Users",
|
||||
"30000_monthly_identified_users": "30000 Monthly Identified Users",
|
||||
"3_projects": "3 Projects",
|
||||
"5000_monthly_responses": "5000 Monthly Responses",
|
||||
"5000_monthly_responses": "5,000 Monthly Responses",
|
||||
"5_projects": "5 Projects",
|
||||
"7500_monthly_identified_users": "7500 Monthly Identified Users",
|
||||
"advanced_targeting": "Advanced Targeting",
|
||||
@@ -1149,6 +1151,7 @@
|
||||
"disable_two_factor_authentication": "Disable two factor authentication",
|
||||
"disable_two_factor_authentication_description": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Each backup code can be used exactly once to grant access without your authenticator.",
|
||||
"email_change_initiated": "Your email change request has been initiated.",
|
||||
"enable_two_factor_authentication": "Enable two factor authentication",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
|
||||
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.",
|
||||
@@ -1766,7 +1769,7 @@
|
||||
"link_to_public_results_copied": "Link to public results copied",
|
||||
"make_sure_the_survey_type_is_set_to": "Make sure the survey type is set to",
|
||||
"mobile_app": "Mobile app",
|
||||
"no_response_matches_filter": "No response matches your filter",
|
||||
"no_responses_found": "No responses found",
|
||||
"only_completed": "Only completed",
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"continue_with_saml": "Continuer avec SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Veuillez confirmer votre mot de passe avant de changer votre adresse e-mail",
|
||||
"email_already_exists": "Cet e-mail est déjà utilisé",
|
||||
"email_change_success": "E-mail changé avec succès",
|
||||
"email_change_success_description": "Vous avez changé votre adresse e-mail avec succès. Veuillez vous connecter avec votre nouvelle adresse e-mail.",
|
||||
"email_verification_failed": "Échec de la vérification de l'email",
|
||||
"email_verification_loading": "Vérification de l'email en cours...",
|
||||
"email_verification_loading_description": "Nous mettons à jour votre adresse email dans notre système. Cela peut prendre quelques secondes.",
|
||||
"invalid_or_expired_token": "Échec du changement d'email. Votre jeton est invalide ou expiré.",
|
||||
"new_email": "Nouvel Email",
|
||||
"old_email": "Ancien Email"
|
||||
@@ -88,11 +89,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Adresse e-mail invalide",
|
||||
"invalid_token": "Jeton non valide ☹️",
|
||||
"new_email_verification_success": "Si l'adresse est valide, un email de vérification a été envoyé.",
|
||||
"no_email_provided": "Aucun e-mail fourni",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Veuillez cliquer sur le lien dans l'e-mail pour activer votre compte.",
|
||||
"please_confirm_your_email_address": "Veuillez confirmer votre adresse e-mail.",
|
||||
"resend_verification_email": "Renvoyer l'email de vérification",
|
||||
"verification_email_successfully_sent": "Email de vérification envoyé à {email}. Veuillez vérifier pour compléter la mise à jour.",
|
||||
"verification_email_resent_successfully": "E-mail de vérification envoyé ! Veuillez vérifier votre boîte de réception.",
|
||||
"we_sent_an_email_to": "Nous avons envoyé un email à {email}",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Vous n'avez pas reçu d'email ou votre lien a expiré ?"
|
||||
},
|
||||
@@ -984,7 +986,7 @@
|
||||
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels",
|
||||
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
|
||||
"3_projects": "3 Projets",
|
||||
"5000_monthly_responses": "5000 Réponses Mensuelles",
|
||||
"5000_monthly_responses": "5,000 Réponses Mensuelles",
|
||||
"5_projects": "5 Projets",
|
||||
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
|
||||
"advanced_targeting": "Ciblage Avancé",
|
||||
@@ -1149,6 +1151,7 @@
|
||||
"disable_two_factor_authentication": "Désactiver l'authentification à deux facteurs",
|
||||
"disable_two_factor_authentication_description": "Si vous devez désactiver l'authentification à deux facteurs, nous vous recommandons de la réactiver dès que possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Chaque code de sauvegarde peut être utilisé exactement une fois pour accorder l'accès sans votre authentificateur.",
|
||||
"email_change_initiated": "Votre demande de changement d'email a été initiée.",
|
||||
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
|
||||
"file_size_must_be_less_than_10mb": "La taille du fichier doit être inférieure à 10 Mo.",
|
||||
@@ -1766,7 +1769,7 @@
|
||||
"link_to_public_results_copied": "Lien vers les résultats publics copié",
|
||||
"make_sure_the_survey_type_is_set_to": "Assurez-vous que le type d'enquête est défini sur",
|
||||
"mobile_app": "Application mobile",
|
||||
"no_response_matches_filter": "Aucune réponse ne correspond à votre filtre",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"only_completed": "Uniquement terminé",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
"overall": "Globalement",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"continue_with_saml": "Continuar com SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Por favor, confirme sua senha antes de mudar seu endereço de e-mail",
|
||||
"email_already_exists": "Este e-mail já está em uso",
|
||||
"email_change_success": "E-mail alterado com sucesso",
|
||||
"email_change_success_description": "Você alterou seu endereço de e-mail com sucesso. Por favor, faça login com seu novo endereço de e-mail.",
|
||||
"email_verification_failed": "Falha na verificação do e-mail",
|
||||
"email_verification_loading": "Verificação de e-mail em andamento...",
|
||||
"email_verification_loading_description": "Estamos atualizando seu endereço de e-mail em nosso sistema. Isso pode levar alguns segundos.",
|
||||
"invalid_or_expired_token": "Falha na alteração do e-mail. Seu token é inválido ou expirou.",
|
||||
"new_email": "Novo Email",
|
||||
"old_email": "Email Antigo"
|
||||
@@ -88,11 +89,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Endereço de email inválido",
|
||||
"invalid_token": "Token inválido ☹️",
|
||||
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
|
||||
"no_email_provided": "Nenhum e-mail fornecido",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clica no link do e-mail pra ativar sua conta.",
|
||||
"please_confirm_your_email_address": "Por favor, confirme seu endereço de e-mail",
|
||||
"resend_verification_email": "Reenviar e-mail de verificação",
|
||||
"verification_email_successfully_sent": "E-mail de verificação enviado para {email}. Verifique para concluir a atualização.",
|
||||
"verification_email_resent_successfully": "E-mail de verificação enviado! Por favor, verifique sua caixa de entrada.",
|
||||
"we_sent_an_email_to": "Enviamos um email para {email}",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Você não recebeu um e-mail ou seu link expirou?"
|
||||
},
|
||||
@@ -984,7 +986,7 @@
|
||||
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente",
|
||||
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
|
||||
"3_projects": "3 Projetos",
|
||||
"5000_monthly_responses": "5000 Respostas Mensais",
|
||||
"5000_monthly_responses": "5,000 Respostas Mensais",
|
||||
"5_projects": "5 Projetos",
|
||||
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
|
||||
"advanced_targeting": "Mira Avançada",
|
||||
@@ -1149,6 +1151,7 @@
|
||||
"disable_two_factor_authentication": "Desativar a autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se você precisar desativar a 2FA, recomendamos reativá-la o mais rápido possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.",
|
||||
@@ -1766,7 +1769,7 @@
|
||||
"link_to_public_results_copied": "Link pros resultados públicos copiado",
|
||||
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de pesquisa esteja definido como",
|
||||
"mobile_app": "app de celular",
|
||||
"no_response_matches_filter": "Nenhuma resposta corresponde ao seu filtro",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Somente concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "No geral",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"continue_with_saml": "Continuar com SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Por favor, confirme a sua palavra-passe antes de alterar o seu endereço de email",
|
||||
"email_already_exists": "Este email já está a ser utilizado",
|
||||
"email_change_success": "Email alterado com sucesso",
|
||||
"email_change_success_description": "Alterou com sucesso o seu endereço de email. Por favor, inicie sessão com o seu novo endereço de email.",
|
||||
"email_verification_failed": "Falha na verificação do email",
|
||||
"email_verification_loading": "Verificação do email em progresso...",
|
||||
"email_verification_loading_description": "Estamos a atualizar o seu endereço de email no nosso sistema. Isto pode demorar alguns segundos.",
|
||||
"invalid_or_expired_token": "Falha na alteração do email. O seu token é inválido ou expirou.",
|
||||
"new_email": "Novo Email",
|
||||
"old_email": "Email Antigo"
|
||||
@@ -88,11 +89,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Endereço de email inválido",
|
||||
"invalid_token": "Token inválido ☹️",
|
||||
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
|
||||
"no_email_provided": "Nenhum email fornecido",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clique no link no email para ativar a sua conta.",
|
||||
"please_confirm_your_email_address": "Por favor, confirme o seu endereço de email",
|
||||
"resend_verification_email": "Reenviar email de verificação",
|
||||
"verification_email_successfully_sent": "Email de verificação enviado para {email}. Por favor, verifique para completar a atualização.",
|
||||
"verification_email_resent_successfully": "Email de verificação enviado! Por favor, verifique a sua caixa de entrada.",
|
||||
"we_sent_an_email_to": "Enviámos um email para {email}. ",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Não recebeu um email ou o seu link expirou?"
|
||||
},
|
||||
@@ -984,7 +986,7 @@
|
||||
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente",
|
||||
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
|
||||
"3_projects": "3 Projetos",
|
||||
"5000_monthly_responses": "5000 Respostas Mensais",
|
||||
"5000_monthly_responses": "5,000 Respostas Mensais",
|
||||
"5_projects": "5 Projetos",
|
||||
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
|
||||
"advanced_targeting": "Segmentação Avançada",
|
||||
@@ -1149,6 +1151,7 @@
|
||||
"disable_two_factor_authentication": "Desativar autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se precisar de desativar a autenticação de dois fatores, recomendamos que a reative o mais rapidamente possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"email_change_initiated": "O seu pedido de alteração de email foi iniciado.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do ficheiro deve ser inferior a 10MB.",
|
||||
@@ -1766,7 +1769,7 @@
|
||||
"link_to_public_results_copied": "Link para resultados públicos copiado",
|
||||
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de inquérito está definido para",
|
||||
"mobile_app": "Aplicação móvel",
|
||||
"no_response_matches_filter": "Nenhuma resposta corresponde ao seu filtro",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Apenas concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "Geral",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"continue_with_saml": "使用 SAML SSO 繼續",
|
||||
"email-change": {
|
||||
"confirm_password_description": "在更改您的電子郵件地址之前,請確認您的密碼",
|
||||
"email_already_exists": "此電子郵件地址已被使用",
|
||||
"email_change_success": "電子郵件已成功更改",
|
||||
"email_change_success_description": "您已成功更改電子郵件地址。請使用您的新電子郵件地址登入。",
|
||||
"email_verification_failed": "電子郵件驗證失敗",
|
||||
"email_verification_loading": "電子郵件驗證進行中...",
|
||||
"email_verification_loading_description": "我們正在系統中更新您的電子郵件地址。這可能需要幾秒鐘。",
|
||||
"invalid_or_expired_token": "電子郵件更改失敗。您的 token 無效或已過期。",
|
||||
"new_email": "新 電子郵件",
|
||||
"old_email": "舊 電子郵件"
|
||||
@@ -88,11 +89,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "無效的電子郵件地址",
|
||||
"invalid_token": "無效的權杖 ☹️",
|
||||
"new_email_verification_success": "如果地址有效,驗證電子郵件已發送。",
|
||||
"no_email_provided": "未提供電子郵件",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "請點擊電子郵件中的連結以啟用您的帳戶。",
|
||||
"please_confirm_your_email_address": "請確認您的電子郵件地址",
|
||||
"resend_verification_email": "重新發送驗證電子郵件",
|
||||
"verification_email_successfully_sent": "验证电子邮件已发送至 {email}。请验证以完成更新。",
|
||||
"verification_email_resent_successfully": "驗證電子郵件已發送!請檢查您的收件箱。",
|
||||
"we_sent_an_email_to": "我們已發送一封電子郵件至 <email>'{'email'}'</email>。",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "您沒有收到電子郵件或您的連結已過期?"
|
||||
},
|
||||
@@ -1149,6 +1151,7 @@
|
||||
"disable_two_factor_authentication": "停用雙重驗證",
|
||||
"disable_two_factor_authentication_description": "如果您需要停用 2FA,我們建議您盡快重新啟用它。",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每個備份碼只能使用一次,以便在沒有驗證器的情況下授予存取權限。",
|
||||
"email_change_initiated": "您的 email 更改請求已啟動。",
|
||||
"enable_two_factor_authentication": "啟用雙重驗證",
|
||||
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
|
||||
"file_size_must_be_less_than_10mb": "檔案大小必須小於 10MB。",
|
||||
@@ -1766,7 +1769,7 @@
|
||||
"link_to_public_results_copied": "已複製公開結果的連結",
|
||||
"make_sure_the_survey_type_is_set_to": "請確保問卷類型設定為",
|
||||
"mobile_app": "行動應用程式",
|
||||
"no_response_matches_filter": "沒有任何回應符合您的篩選器",
|
||||
"no_responses_found": "找不到回應",
|
||||
"only_completed": "僅已完成",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Response } from "node-fetch";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { createBrevoCustomer } from "./brevo";
|
||||
import { createBrevoCustomer, updateBrevoCustomer } from "./brevo";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
BREVO_API_KEY: "mock_api_key",
|
||||
@@ -42,18 +41,87 @@ describe("createBrevoCustomer", () => {
|
||||
|
||||
await createBrevoCustomer({ id: "123", email: "test@example.com" });
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error sending user to Brevo");
|
||||
});
|
||||
|
||||
test("should log the error response if fetch status is not 200", async () => {
|
||||
test("should log the error response if fetch status is not 201", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "error");
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(
|
||||
new Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
);
|
||||
|
||||
await createBrevoCustomer({ id: "123", email: "test@example.com" });
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error sending user to Brevo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateBrevoCustomer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return early if BREVO_API_KEY is not defined", async () => {
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
BREVO_API_KEY: undefined,
|
||||
BREVO_LIST_ID: "123",
|
||||
}));
|
||||
|
||||
const { updateBrevoCustomer } = await import("./brevo"); // Re-import to get the mocked version
|
||||
|
||||
const result = await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(validateInputs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should log an error if fetch fails", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "error");
|
||||
|
||||
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Fetch failed"));
|
||||
|
||||
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error updating user in Brevo");
|
||||
});
|
||||
|
||||
test("should log the error response if fetch status is not 204", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "error");
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(
|
||||
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
);
|
||||
|
||||
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error updating user in Brevo");
|
||||
});
|
||||
|
||||
test("should successfully update a Brevo customer", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(new global.Response(null, { status: 204 }));
|
||||
|
||||
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.brevo.com/v3/contacts/user123?identifierType=ext_id",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"api-key": "mock_api_key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
attributes: { EMAIL: "test@example.com" },
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,26 @@ import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
|
||||
|
||||
type BrevoCreateContact = {
|
||||
email?: string;
|
||||
ext_id?: string;
|
||||
attributes?: Record<string, string | string[]>;
|
||||
emailBlacklisted?: boolean;
|
||||
smsBlacklisted?: boolean;
|
||||
listIds?: number[];
|
||||
updateEnabled?: boolean;
|
||||
smtpBlacklistSender?: string[];
|
||||
};
|
||||
|
||||
type BrevoUpdateContact = {
|
||||
attributes?: Record<string, string>;
|
||||
emailBlacklisted?: boolean;
|
||||
smsBlacklisted?: boolean;
|
||||
listIds?: number[];
|
||||
unlinkListIds?: number[];
|
||||
smtpBlacklistSender?: string[];
|
||||
};
|
||||
|
||||
export const createBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => {
|
||||
if (!BREVO_API_KEY) {
|
||||
return;
|
||||
@@ -12,7 +32,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
|
||||
validateInputs([id, ZId], [email, ZUserEmail]);
|
||||
|
||||
try {
|
||||
const requestBody: any = {
|
||||
const requestBody: BrevoCreateContact = {
|
||||
email,
|
||||
ext_id: id,
|
||||
updateEnabled: false,
|
||||
@@ -34,7 +54,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
if (res.status !== 201) {
|
||||
const errorText = await res.text();
|
||||
logger.error({ errorText }, "Error sending user to Brevo");
|
||||
}
|
||||
@@ -42,3 +62,36 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
|
||||
logger.error(error, "Error sending user to Brevo");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => {
|
||||
if (!BREVO_API_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
validateInputs([id, ZId], [email, ZUserEmail]);
|
||||
|
||||
try {
|
||||
const requestBody: BrevoUpdateContact = {
|
||||
attributes: {
|
||||
EMAIL: email,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(`https://api.brevo.com/v3/contacts/${id}?identifierType=ext_id`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"api-key": BREVO_API_KEY,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
const errorText = await res.text();
|
||||
logger.error({ errorText }, "Error updating user in Brevo");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Error updating user in Brevo");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,17 +5,15 @@ import { getTranslate } from "@/tolgee/server";
|
||||
export const SignupWithoutVerificationSuccessPage = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
|
||||
<FormWrapper>
|
||||
<h1 className="leading-2 mb-4 text-center font-bold">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created")}
|
||||
</h1>
|
||||
<p className="text-center text-sm">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created_description")}
|
||||
</p>
|
||||
<hr className="my-4" />
|
||||
<BackToLoginButton />
|
||||
</FormWrapper>
|
||||
</div>
|
||||
<FormWrapper>
|
||||
<h1 className="leading-2 mb-4 text-center font-bold">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created")}
|
||||
</h1>
|
||||
<p className="text-center text-sm">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created_description")}
|
||||
</p>
|
||||
<hr className="my-4" />
|
||||
<BackToLoginButton />
|
||||
</FormWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,8 +12,8 @@ vi.mock("@tolgee/react", () => ({
|
||||
if (key === "auth.verification-requested.no_email_provided") {
|
||||
return "No email provided";
|
||||
}
|
||||
if (key === "auth.verification-requested.verification_email_successfully_sent") {
|
||||
return `Verification email sent to ${params?.email}`;
|
||||
if (key === "auth.verification-requested.verification_email_resent_successfully") {
|
||||
return `Verification email sent! Please check your inbox.`;
|
||||
}
|
||||
if (key === "auth.verification-requested.resend_verification_email") {
|
||||
return "Resend verification email";
|
||||
@@ -61,7 +61,7 @@ describe("RequestVerificationEmail", () => {
|
||||
await fireEvent.click(button);
|
||||
|
||||
expect(resendVerificationEmailAction).toHaveBeenCalledWith({ email: mockEmail });
|
||||
expect(toast.success).toHaveBeenCalledWith(`Verification email sent to ${mockEmail}`);
|
||||
expect(toast.success).toHaveBeenCalledWith(`Verification email sent! Please check your inbox.`);
|
||||
});
|
||||
|
||||
test("reloads page when visibility changes to visible", () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export const RequestVerificationEmail = ({ email }: RequestVerificationEmailProp
|
||||
if (!email) return toast.error(t("auth.verification-requested.no_email_provided"));
|
||||
const response = await resendVerificationEmailAction({ email });
|
||||
if (response?.data) {
|
||||
toast.success(t("auth.verification-requested.verification_email_successfully_sent", { email }));
|
||||
toast.success(t("auth.verification-requested.verification_email_resent_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
toast.error(errorMessage);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { verifyEmailChangeToken } from "@/lib/jwt";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { updateUser } from "@/modules/auth/lib/user";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -17,5 +18,6 @@ export const verifyEmailChangeAction = actionClient
|
||||
if (!user) {
|
||||
throw new Error("User not found or email update failed");
|
||||
}
|
||||
await updateBrevoCustomer({ id: user.id, email: user.email });
|
||||
return user;
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("EmailChangeSignIn", () => {
|
||||
|
||||
test("shows loading state initially", () => {
|
||||
render(<EmailChangeSignIn token="valid-token" />);
|
||||
expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument();
|
||||
expect(screen.getByText("auth.email-change.email_verification_loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles successful email change verification", async () => {
|
||||
|
||||
@@ -5,7 +5,11 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const EmailChangeSignIn = ({ token }: { token: string }) => {
|
||||
interface EmailChangeSignInProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const EmailChangeSignIn = ({ token }: EmailChangeSignInProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [status, setStatus] = useState<"success" | "error" | "loading">("loading");
|
||||
|
||||
@@ -37,18 +41,25 @@ export const EmailChangeSignIn = ({ token }: { token: string }) => {
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const text = {
|
||||
heading: {
|
||||
success: t("auth.email-change.email_change_success"),
|
||||
error: t("auth.email-change.email_verification_failed"),
|
||||
loading: t("auth.email-change.email_verification_loading"),
|
||||
},
|
||||
description: {
|
||||
success: t("auth.email-change.email_change_success_description"),
|
||||
error: t("auth.email-change.invalid_or_expired_token"),
|
||||
loading: t("auth.email-change.email_verification_loading_description"),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className={`leading-2 mb-4 text-center font-bold ${status === "error" ? "text-red-600" : ""}`}>
|
||||
{status === "success"
|
||||
? t("auth.email-change.email_change_success")
|
||||
: t("auth.email-change.email_verification_failed")}
|
||||
{text.heading[status]}
|
||||
</h1>
|
||||
<p className="text-center text-sm">
|
||||
{status === "success"
|
||||
? t("auth.email-change.email_change_success_description")
|
||||
: t("auth.email-change.invalid_or_expired_token")}
|
||||
</p>
|
||||
<p className="text-center text-sm">{text.description[status]}</p>
|
||||
<hr className="my-4" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -510,7 +510,7 @@ describe("SegmentFilter", () => {
|
||||
qualifier: {
|
||||
operator: "greaterThan",
|
||||
},
|
||||
value: "10",
|
||||
value: "hello",
|
||||
};
|
||||
|
||||
const segmentWithArithmeticFilter: TSegment = {
|
||||
@@ -527,7 +527,7 @@ describe("SegmentFilter", () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithArithmeticFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={arithmeticFilterResource} />);
|
||||
|
||||
const valueInput = screen.getByDisplayValue("10");
|
||||
const valueInput = screen.getByDisplayValue("hello");
|
||||
await userEvent.clear(valueInput);
|
||||
fireEvent.change(valueInput, { target: { value: "abc" } });
|
||||
|
||||
@@ -694,7 +694,7 @@ describe("SegmentFilter", () => {
|
||||
id: "filter-person-2",
|
||||
root: { type: "person", personIdentifier: "userId" },
|
||||
qualifier: { operator: "greaterThan" },
|
||||
value: "10",
|
||||
value: "hello",
|
||||
};
|
||||
|
||||
const segmentWithPersonFilterArithmetic: TSegment = {
|
||||
@@ -715,7 +715,7 @@ describe("SegmentFilter", () => {
|
||||
resource={personFilterResourceWithArithmeticOperator}
|
||||
/>
|
||||
);
|
||||
const valueInput = screen.getByDisplayValue("10");
|
||||
const valueInput = screen.getByDisplayValue("hello");
|
||||
|
||||
await userEvent.clear(valueInput);
|
||||
fireEvent.change(valueInput, { target: { value: "abc" } });
|
||||
|
||||
@@ -236,7 +236,7 @@ function AttributeSegmentFilter({
|
||||
setValueError(t("environments.segments.value_must_be_a_number"));
|
||||
}
|
||||
}
|
||||
}, [resource.qualifier, resource.value]);
|
||||
}, [resource.qualifier, resource.value, t]);
|
||||
|
||||
const operatorArr = ATTRIBUTE_OPERATORS.map((operator) => {
|
||||
return {
|
||||
@@ -327,7 +327,7 @@ function AttributeSegmentFilter({
|
||||
<SelectContent>
|
||||
{contactAttributeKeys.map((attrClass) => (
|
||||
<SelectItem key={attrClass.id} value={attrClass.key}>
|
||||
{attrClass.name}
|
||||
{attrClass.name ?? attrClass.key}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -422,7 +422,7 @@ function PersonSegmentFilter({
|
||||
setValueError(t("environments.segments.value_must_be_a_number"));
|
||||
}
|
||||
}
|
||||
}, [resource.qualifier, resource.value]);
|
||||
}, [resource.qualifier, resource.value, t]);
|
||||
|
||||
const operatorArr = PERSON_OPERATORS.map((operator) => {
|
||||
return {
|
||||
|
||||
@@ -200,11 +200,11 @@ export const TeamSettingsModal = ({
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
noPadding
|
||||
className="overflow-visible"
|
||||
className="flex max-h-[90dvh] flex-col overflow-visible"
|
||||
size="md"
|
||||
hideCloseButton
|
||||
closeOnOutsideClick={true}>
|
||||
<div className="sticky top-0 flex h-full flex-col rounded-lg">
|
||||
<div className="sticky top-0 z-10 rounded-t-lg bg-slate-100">
|
||||
<button
|
||||
className={cn(
|
||||
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
|
||||
@@ -213,27 +213,27 @@ export const TeamSettingsModal = ({
|
||||
<XIcon className="h-6 w-6 rounded-md bg-white" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<H4>
|
||||
{t("environments.settings.teams.team_name_settings_title", {
|
||||
teamName: team.name,
|
||||
})}
|
||||
</H4>
|
||||
<Muted className="text-slate-500">
|
||||
{t("environments.settings.teams.team_settings_description")}
|
||||
</Muted>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<H4>
|
||||
{t("environments.settings.teams.team_name_settings_title", {
|
||||
teamName: team.name,
|
||||
})}
|
||||
</H4>
|
||||
<Muted className="text-slate-500">
|
||||
{t("environments.settings.teams.team_settings_description")}
|
||||
</Muted>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full" onSubmit={handleSubmit(handleUpdateTeam)}>
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="max-h-[500px] space-y-6 overflow-y-auto">
|
||||
<form
|
||||
className="flex w-full flex-grow flex-col overflow-hidden"
|
||||
onSubmit={handleSubmit(handleUpdateTeam)}>
|
||||
<div className="flex-grow space-y-6 overflow-y-auto p-6">
|
||||
<div className="space-y-6">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
@@ -512,6 +512,8 @@ export const TeamSettingsModal = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 z-10 border-slate-200 p-6">
|
||||
<div className="flex justify-between">
|
||||
<Button size="default" type="button" variant="outline" onClick={closeSettingsModal}>
|
||||
{t("common.cancel")}
|
||||
|
||||
@@ -78,6 +78,15 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
alternates: {
|
||||
canonical: `/s/${mockSurveyId}`,
|
||||
},
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: true,
|
||||
noimageindex: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,6 +162,15 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
alternates: {
|
||||
canonical: `/s/${mockSurveyId}`,
|
||||
},
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: true,
|
||||
noimageindex: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,6 +201,15 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
alternates: {
|
||||
canonical: `/s/${mockSurveyId}`,
|
||||
},
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: true,
|
||||
noimageindex: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,5 +35,14 @@ export const getMetadataForLinkSurvey = async (surveyId: string): Promise<Metada
|
||||
alternates: {
|
||||
canonical: canonicalPath,
|
||||
},
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: true,
|
||||
noimageindex: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as React from "react";
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & { blur?: boolean }
|
||||
>(({ className, blur, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
@@ -35,7 +35,7 @@ const sizeClassName = {
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & DialogContentProps
|
||||
>(
|
||||
(
|
||||
|
||||
@@ -23,7 +23,6 @@ const nextConfig = {
|
||||
productionBrowserSourceMaps: false,
|
||||
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
|
||||
outputFileTracingIncludes: {
|
||||
"app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"],
|
||||
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
|
||||
},
|
||||
experimental: {},
|
||||
@@ -189,7 +188,8 @@ const nextConfig = {
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=3600, s-maxage=604800, stale-while-revalidate=3600, stale-if-error=3600",
|
||||
value:
|
||||
"public, max-age=3600, s-maxage=2592000, stale-while-revalidate=3600, stale-if-error=86400",
|
||||
},
|
||||
{
|
||||
key: "Content-Type",
|
||||
@@ -199,20 +199,151 @@ const nextConfig = {
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "*",
|
||||
},
|
||||
{
|
||||
key: "Vary",
|
||||
value: "Accept-Encoding",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// headers for /api/packages/(.*) -- the api route does not exist, but we still need the headers for the rewrites to work correctly!
|
||||
// Favicon files - long cache since they rarely change
|
||||
{
|
||||
source: "/api/packages/(.*)",
|
||||
source: "/favicon/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=3600, s-maxage=604800, stale-while-revalidate=3600, stale-if-error=3600",
|
||||
value: "public, max-age=2592000, s-maxage=31536000, immutable",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Root favicon.ico - long cache
|
||||
{
|
||||
source: "/favicon.ico",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=2592000, s-maxage=31536000, immutable",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
// SVG files (icons, logos) - long cache since they're usually static
|
||||
{
|
||||
source: "/(.*)\\.svg",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=2592000, s-maxage=31536000, immutable",
|
||||
},
|
||||
{
|
||||
key: "Content-Type",
|
||||
value: "application/javascript; charset=UTF-8",
|
||||
value: "image/svg+xml",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Image backgrounds - medium cache (might update more frequently)
|
||||
{
|
||||
source: "/image-backgrounds/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=86400, s-maxage=2592000, stale-while-revalidate=86400",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "*",
|
||||
},
|
||||
{
|
||||
key: "Vary",
|
||||
value: "Accept-Encoding",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Video files - long cache since they're large and expensive to transfer
|
||||
{
|
||||
source: "/video/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=604800, s-maxage=31536000, stale-while-revalidate=604800",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "*",
|
||||
},
|
||||
{
|
||||
key: "Accept-Ranges",
|
||||
value: "bytes",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Animated backgrounds (4K videos) - very long cache since they're large and immutable
|
||||
{
|
||||
source: "/animated-bgs/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=604800, s-maxage=31536000, immutable",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "*",
|
||||
},
|
||||
{
|
||||
key: "Accept-Ranges",
|
||||
value: "bytes",
|
||||
},
|
||||
],
|
||||
},
|
||||
// CSV templates - shorter cache since they might update with feature changes
|
||||
{
|
||||
source: "/sample-csv/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=3600",
|
||||
},
|
||||
{
|
||||
key: "Content-Type",
|
||||
value: "text/csv",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Web manifest and browser config files - medium cache
|
||||
{
|
||||
source: "/(site\\.webmanifest|browserconfig\\.xml)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Optimize caching for other static assets in public folder (fallback)
|
||||
{
|
||||
source: "/(images|fonts|icons)/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=31536000, s-maxage=31536000, immutable",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
|
||||
@@ -138,7 +138,7 @@ test.describe("JS Package Test", async () => {
|
||||
const impressionsCount = await page.getByRole("button", { name: "Impressions" }).innerText();
|
||||
expect(impressionsCount).toEqual("Impressions\n\n1");
|
||||
|
||||
await expect(page.getByRole("link", { name: "Responses (1)" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Responses" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Completed 100%" })).toBeVisible();
|
||||
await expect(page.getByText("1 Responses", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("CTR100%")).toBeVisible();
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 162 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="31" fill="none"><g opacity=".9"><path fill="url(#a)" d="M13 .4v29.3H7V6.3h-.2L0 10.5V5L7.2.4H13Z"/><path fill="url(#b)" d="M28.8 30.1c-2.2 0-4-.3-5.7-1-1.7-.8-3-1.8-4-3.1a7.7 7.7 0 0 1-1.4-4.6h6.2c0 .8.3 1.4.7 2 .4.5 1 .9 1.7 1.2.7.3 1.6.4 2.5.4 1 0 1.7-.2 2.5-.5.7-.3 1.3-.8 1.7-1.4.4-.6.6-1.2.6-2s-.2-1.5-.7-2.1c-.4-.6-1-1-1.8-1.4-.8-.4-1.8-.5-2.9-.5h-2.7v-4.6h2.7a6 6 0 0 0 2.5-.5 4 4 0 0 0 1.7-1.3c.4-.6.6-1.3.6-2a3.5 3.5 0 0 0-2-3.3 5.6 5.6 0 0 0-4.5 0 4 4 0 0 0-1.7 1.2c-.4.6-.6 1.2-.6 2h-6c0-1.7.6-3.2 1.5-4.5 1-1.3 2.2-2.3 3.8-3C25 .4 26.8 0 28.8 0s3.8.4 5.3 1.1c1.5.7 2.7 1.7 3.6 3a7.2 7.2 0 0 1 1.2 4.2c0 1.6-.5 3-1.5 4a7 7 0 0 1-4 2.2v.2c2.2.3 3.8 1 5 2.2a6.4 6.4 0 0 1 1.6 4.6c0 1.7-.5 3.1-1.4 4.4a9.7 9.7 0 0 1-4 3.1c-1.7.8-3.7 1.1-5.8 1.1Z"/></g><defs><linearGradient id="a" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient><linearGradient id="b" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
Before Width: | Height: | Size: 629 B |
@@ -91,6 +91,7 @@ export default defineConfig({
|
||||
"packages/surveys/src/components/general/smileys.tsx", // Smiley components
|
||||
"modules/analysis/components/SingleResponseCard/components/Smileys.tsx", // Analysis smiley components
|
||||
"modules/auth/lib/mock-data.ts", // Mock data for authentication
|
||||
"packages/js-core/src/index.ts", // JS Core index file
|
||||
|
||||
// Other
|
||||
"**/scripts/**", // Utility scripts
|
||||
|
||||
@@ -180,25 +180,23 @@ tls:
|
||||
default:
|
||||
minVersion: VersionTLS12
|
||||
cipherSuites:
|
||||
# TLS 1.2 Ciphers
|
||||
# TLS 1.2 strong ciphers
|
||||
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
|
||||
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
|
||||
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
|
||||
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
|
||||
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
|
||||
# TLS 1.3 Ciphers (These are automatically used for TLS 1.3 connections)
|
||||
- TLS_AES_128_GCM_SHA256
|
||||
- TLS_AES_256_GCM_SHA384
|
||||
- TLS_CHACHA20_POLY1305_SHA256
|
||||
|
||||
# Fallback
|
||||
- TLS_FALLBACK_SCSV
|
||||
# TLS 1.3 ciphers are not configurable in Traefik; they are enabled by default
|
||||
curvePreferences:
|
||||
- CurveP521
|
||||
- CurveP384
|
||||
sniStrict: true
|
||||
alpnProtocols:
|
||||
- h2
|
||||
- http/1.1
|
||||
EOT
|
||||
|
||||
echo "💡 Created traefik.yaml and traefik-dynamic.yaml file."
|
||||
|
||||
@@ -44,4 +44,4 @@ We currently have the following Management API methods exposed and below is thei
|
||||
|
||||
---
|
||||
|
||||
**Can’t figure it out?** Get help in [GitHub Discussions](https://github.com/formbricks/formbricks/discussions).
|
||||
**Need help?** Reach out in [GitHub Discussions](https://github.com/formbricks/formbricks/discussions).
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 139 KiB |
@@ -1,74 +1,77 @@
|
||||
---
|
||||
title: "Email Follow-ups"
|
||||
description: "Follow-ups are a feature that allows you to send emails to your users on different survey events."
|
||||
description: "Automatically send customized emails to respondents based on their survey responses or specific survey endings."
|
||||
icon: "envelope"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The email followup feature allows survey creators to automatically send customized emails to respondents based on their survey responses or when they reach specific survey endings. This feature is particularly useful for following up with respondents, sending thank you notes, or providing additional information.
|
||||
|
||||
<Note>
|
||||
Email followups is a paid feature. It is only available for users on paid plans or if you have [Enterprise Edition](/self-hosting/advanced/license).
|
||||
</Note>
|
||||
|
||||
## Key Components
|
||||
## What are Email Follow-ups?
|
||||
|
||||
### 1. Trigger Types
|
||||
Email followups allow you to automatically send customized emails to respondents based on their survey responses or when they reach specific survey endings. This feature is perfect for:
|
||||
- Sending thank you notes
|
||||
- Following up with respondents
|
||||
- Providing additional information
|
||||
- Sharing survey response data
|
||||
|
||||
There are two types of triggers for email followups:
|
||||
### Trigger Types
|
||||
|
||||
- **Response-based**: Triggered when a response is submitted
|
||||
- **Ending-based**: Triggered when respondents reach specific survey endings
|
||||
<Card title="Response-based">
|
||||
Emails are sent when a response to your survey is completed.
|
||||
</Card>
|
||||
|
||||
### 2. Email Configuration
|
||||
<Card title="Ending-based">
|
||||
Emails are triggered when respondents reach specific survey endings.
|
||||
</Card>
|
||||
|
||||
Each followup email can be configured with:
|
||||
## Setting Up Email Follow-ups
|
||||
|
||||
- **Name**: A descriptive name for the followup
|
||||
- **To**: Email recipient (sourced from):
|
||||
- Open text questions with email input type
|
||||
- Contact info questions
|
||||
- Hidden fields
|
||||
- **Reply-To**: One or more email addresses for replies
|
||||
- **Subject**: Email subject line
|
||||
- **Body**: HTML-formatted email content
|
||||
<Steps>
|
||||
<Step title="Go to Follow-ups Section and Create New Follow-up">
|
||||
Navigate to the survey editor and access the Follow-ups section.
|
||||
</Step>
|
||||
|
||||
## Setup Process
|
||||
<Step title="Configure Recipients">
|
||||
The "To" field can be configured to use:
|
||||
|
||||
1. Navigate to the survey editor
|
||||
2. Access the `follow-ups` section
|
||||
<ul>
|
||||
<li><strong>Email Questions:</strong> Responses to question type `Open Text` of type `email`</li>
|
||||
<li><strong>Contact Info:</strong> Responses to question type `Contact`</li>
|
||||
<li><strong>Hidden Fields:</strong> Values from hidden fields</li>
|
||||
<li><strong>Team Members:</strong> Members of your team</li>
|
||||
<li><strong>Yourself:</strong> Your own email address</li>
|
||||
</ul>
|
||||
|
||||

|
||||
<Image src="/images/xm-and-surveys/core-features/email-followups/followup-recipient.webp" alt="Followup recipient configuration" />
|
||||
</Step>
|
||||
|
||||
3. Click the "New follow-up" button to add a new followup
|
||||
4. Fill in the required information:
|
||||
<Step title="Set Up Reply-To">
|
||||
- Add one or more valid email addresses
|
||||
- Addresses can be added by typing and pressing space or comma
|
||||
- Invalid email addresses are automatically rejected
|
||||
</Step>
|
||||
|
||||
- Followup name
|
||||
- Trigger type (response or endings)
|
||||
<Step title="Configure Email Content">
|
||||
<Image src="/images/xm-and-surveys/core-features/email-followups/followup-content.webp" alt="Followup content configuration" />
|
||||
|
||||

|
||||
<ul>
|
||||
<li><strong>Subject:</strong> Customize your email subject line</li>
|
||||
<li><strong>Body:</strong> Supports basic HTML formatting (`p`, `span`, `b`, `strong`, `i`, `em`, `a`, `br` tags)</li>
|
||||
<li>
|
||||
<strong>Survey Response Data:</strong> Option to include detailed response data with support for:
|
||||
<ul>
|
||||
<li>File uploads</li>
|
||||
<li>Images</li>
|
||||
<li>Rankings</li>
|
||||
<li>Translations</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</Step>
|
||||
|
||||
5. **Configuring Recipients**:
|
||||
The "To" field can be configured to use:
|
||||
|
||||
- Responses from email-type open text questions
|
||||
- Responses from contact info questions
|
||||
- Values from hidden fields
|
||||
|
||||
6. **Configure the Reply-To**:
|
||||
|
||||
- Add one or more valid email addresses
|
||||
- Addresses can be added by typing and pressing space or comma
|
||||
- Invalid email addresses are automatically rejected
|
||||
|
||||

|
||||
|
||||
7. **Configuring the Email Content**:
|
||||
|
||||
- Subject
|
||||
- Body: Supports basic HTML formatting (p, span, b, strong, i, em, a, br tags)
|
||||
|
||||

|
||||
|
||||
8. **Save and Activate**
|
||||
<Step title="Save to Activate">
|
||||
Once you've configured all settings, save your survey to activate the email follow-up.
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -51,4 +51,4 @@ You can export the metadata of your responses along with the response data. When
|
||||
|
||||
---
|
||||
|
||||
**Can’t figure it out?**: [Get help in Github Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
**Need help?** [Reach out in Github Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
|
||||
@@ -108,4 +108,31 @@ Without the `lang` parameter, Formbricks will show the survey in the default lan
|
||||
|
||||
You can now start collecting responses in multiple languages!
|
||||
|
||||
**Can’t figure it out?**: [Get help in Github Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
---
|
||||
|
||||
## RTL Language Support
|
||||
|
||||
Formbricks fully supports Right-to-Left (RTL) languages such as Arabic, Hebrew, Persian, and Urdu. When you add an RTL language to your survey, the survey interface automatically adjusts to display content from right to left.
|
||||
|
||||
### How RTL Support Works
|
||||
|
||||
- Text alignment automatically switches to right-to-left
|
||||
- Survey layout and UI elements adjust to RTL orientation
|
||||
- Button placement and navigation flow adapt to RTL reading direction
|
||||
- Form elements maintain proper RTL formatting
|
||||
|
||||
### Setting Up RTL Languages
|
||||
|
||||
No additional configuration is needed to enable RTL support. Simply:
|
||||
|
||||
1. Add an RTL language (like Arabic or Hebrew) in the **Survey Languages** settings
|
||||
2. Create translations for your survey content in the RTL language
|
||||
3. The survey will automatically display in RTL format when that language is selected
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Need help?** [Reach out in Github Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
|
||||
@@ -194,4 +194,4 @@ PS: If you do not see any signature settings, just use one of the methods we've
|
||||
|
||||
---
|
||||
|
||||
**Can’t figure it out?**: [Get help in Github Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
**Need help?** [Reach out in Github Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
|
||||
@@ -33,6 +33,10 @@ Integrate the **Formbricks App Survey SDK** into your app using multiple options
|
||||
[Use our iOS SDK to quickly integrate surveys into your iOS applications.](https://formbricks.com/docs/app-surveys/framework-guides#swift)
|
||||
</Card>
|
||||
|
||||
<Card title="Android" icon="android" color="green" href="#android">
|
||||
[Integrate surveys into your Android applications using our native Kotlin SDK.](https://formbricks.com/docs/app-surveys/framework-guides#android)
|
||||
</Card>
|
||||
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
@@ -409,6 +413,77 @@ Formbricks.cleanup(waitForOperations: true) {
|
||||
| environment-id | string | Formbricks Environment ID. |
|
||||
| app-url | string | URL of the hosted Formbricks instance. |
|
||||
|
||||
|
||||
Now, visit the [Validate Your Setup](#validate-your-setup) section to verify your setup!
|
||||
|
||||
## Android
|
||||
|
||||
Install the Formbricks Android SDK using the following steps:
|
||||
|
||||
### Installation
|
||||
|
||||
Add the Maven Central repository and the Formbricks SDK dependency to your application's `build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.formbricks:android:1.0.0") // replace with latest version
|
||||
}
|
||||
```
|
||||
|
||||
Enable DataBinding in your app's module build.gradle.kts:
|
||||
|
||||
```kotlin
|
||||
android {
|
||||
buildFeatures {
|
||||
dataBinding = true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```kotlin
|
||||
// 1. Initialize the SDK
|
||||
val config = FormbricksConfig.Builder(
|
||||
"https://your-formbricks-server.com",
|
||||
"YOUR_ENVIRONMENT_ID"
|
||||
)
|
||||
.setLoggingEnabled(true)
|
||||
.setFragmentManager(supportFragmentManager)
|
||||
.build()
|
||||
|
||||
// 2. Setup Formbricks
|
||||
Formbricks.setup(this, config)
|
||||
|
||||
// 3. Identify the user
|
||||
Formbricks.setUserId("user‑123")
|
||||
|
||||
// 4. Track events
|
||||
Formbricks.track("button_pressed")
|
||||
|
||||
// 5. Set or add user attributes
|
||||
Formbricks.setAttribute("test@web.com", "email")
|
||||
Formbricks.setAttributes(mapOf(Pair("attr1", "val1"), Pair("attr2", "val2")))
|
||||
|
||||
// 6. Change language (no userId required):
|
||||
Formbricks.setLanguage("de")
|
||||
|
||||
// 7. Log out:
|
||||
Formbricks.logout()
|
||||
```
|
||||
|
||||
### Required Customizations
|
||||
|
||||
| Name | Type | Description |
|
||||
| -------------- | ------ | -------------------------------------- |
|
||||
| environment-id | string | Formbricks Environment ID. |
|
||||
| app-url | string | URL of the hosted Formbricks instance. |
|
||||
|
||||
## Validate your setup
|
||||
|
||||
Once you’ve completed the steps above, validate your setup by checking the Setup Checklist in the Settings. The widget status indicator should change from this:
|
||||
@@ -420,6 +495,10 @@ To this:
|
||||
|
||||
## Debugging Formbricks Integration
|
||||
|
||||
<Note>
|
||||
The debug mode is only available in the JavaScript SDK and works exclusively in the browser. It is not supported in mobile SDKs such as React Native, iOS, or Android.
|
||||
</Note>
|
||||
|
||||
Enabling debug mode in your browser can help troubleshoot issues with Formbricks. Here’s how to activate it and what to look for in the logs.
|
||||
|
||||
### Activate Debug Mode
|
||||
|
||||
@@ -375,4 +375,4 @@ And lastly, in the `updateFeedback` function
|
||||
|
||||
Something doesn’t work? Check your browser console for the error.
|
||||
|
||||
**Can’t figure it out?**: [Get help in GitHub Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
**Need help?** [Reach out in GitHub Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
@@ -93,6 +93,51 @@ deployment:
|
||||
nodeSelector:
|
||||
karpenter.sh/capacity-type: spot
|
||||
reloadOnChange: true
|
||||
# Pod lifecycle management for zero-downtime deployments
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["/bin/sh", "-c", "sleep 15"]
|
||||
# Health probes configuration
|
||||
probes:
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
successThreshold: 1
|
||||
failureThreshold: 3
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
successThreshold: 1
|
||||
failureThreshold: 3
|
||||
startup:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
successThreshold: 1
|
||||
failureThreshold: 12
|
||||
# Pod termination grace period
|
||||
terminationGracePeriodSeconds: 45
|
||||
# Rolling update strategy
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 25%
|
||||
maxSurge: 50%
|
||||
autoscaling:
|
||||
enabled: true
|
||||
maxReplicas: 95
|
||||
@@ -136,9 +181,32 @@ ingress:
|
||||
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/ssl-policy: ELBSecurityPolicy-TLS13-1-2-Res-2021-06
|
||||
alb.ingress.kubernetes.io/ssl-redirect: "443"
|
||||
alb.ingress.kubernetes.io/target-type: ip
|
||||
# Enhanced ALB configuration for connection handling
|
||||
alb.ingress.kubernetes.io/load-balancer-attributes: |
|
||||
idle_timeout.timeout_seconds=120,
|
||||
connection_logs.s3.enabled=false,
|
||||
access_logs.s3.enabled=false
|
||||
# Target group health check optimizations
|
||||
alb.ingress.kubernetes.io/target-group-attributes: |
|
||||
deregistration_delay.timeout_seconds=30,
|
||||
stickiness.enabled=false,
|
||||
stickiness.type=lb_cookie,
|
||||
stickiness.lb_cookie.duration_seconds=86400,
|
||||
load_balancing.algorithm.type=least_outstanding_requests,
|
||||
target_group_health.dns_failover.minimum_healthy_targets.count=1,
|
||||
target_group_health.dns_failover.minimum_healthy_targets.percentage=off
|
||||
# Health check configuration
|
||||
alb.ingress.kubernetes.io/healthcheck-interval-seconds: "15"
|
||||
alb.ingress.kubernetes.io/healthcheck-timeout-seconds: "5"
|
||||
alb.ingress.kubernetes.io/healthy-threshold-count: "2"
|
||||
alb.ingress.kubernetes.io/unhealthy-threshold-count: "3"
|
||||
alb.ingress.kubernetes.io/success-codes: "200"
|
||||
# Backend protocol and port
|
||||
alb.ingress.kubernetes.io/backend-protocol: HTTP
|
||||
alb.ingress.kubernetes.io/backend-protocol-version: HTTP1
|
||||
enabled: true
|
||||
hosts:
|
||||
- host: stage.app.formbricks.com
|
||||
@@ -163,3 +231,16 @@ postgresql:
|
||||
enabled: false
|
||||
redis:
|
||||
enabled: false
|
||||
|
||||
## Service Configuration
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
targetPort: 3000
|
||||
annotations:
|
||||
# Service annotations for better ALB integration
|
||||
service.beta.kubernetes.io/aws-load-balancer-backend-protocol: http
|
||||
service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout: "120"
|
||||
service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"
|
||||
# Session affinity disabled for better load distribution
|
||||
sessionAffinity: None
|
||||
|
||||
@@ -82,8 +82,6 @@ deployment:
|
||||
env:
|
||||
DOCKER_CRON_ENABLED:
|
||||
value: "0"
|
||||
RATE_LIMITING_DISABLED:
|
||||
value: "1"
|
||||
envFrom:
|
||||
app-env:
|
||||
nameSuffix: app-env
|
||||
@@ -91,6 +89,51 @@ deployment:
|
||||
nodeSelector:
|
||||
karpenter.sh/capacity-type: on-demand
|
||||
reloadOnChange: true
|
||||
# Pod lifecycle management for zero-downtime deployments
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["/bin/sh", "-c", "sleep 15"]
|
||||
# Health probes configuration
|
||||
probes:
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
successThreshold: 1
|
||||
failureThreshold: 3
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
successThreshold: 1
|
||||
failureThreshold: 3
|
||||
startup:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
successThreshold: 1
|
||||
failureThreshold: 12
|
||||
# Pod termination grace period
|
||||
terminationGracePeriodSeconds: 45
|
||||
# Rolling update strategy
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 25%
|
||||
maxSurge: 50%
|
||||
autoscaling:
|
||||
enabled: true
|
||||
maxReplicas: 95
|
||||
@@ -137,6 +180,39 @@ ingress:
|
||||
alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-2021-06
|
||||
alb.ingress.kubernetes.io/ssl-redirect: "443"
|
||||
alb.ingress.kubernetes.io/target-type: ip
|
||||
# Enhanced ALB configuration for connection handling
|
||||
alb.ingress.kubernetes.io/load-balancer-attributes: |
|
||||
idle_timeout.timeout_seconds=120,
|
||||
connection_logs.s3.enabled=false,
|
||||
access_logs.s3.enabled=false
|
||||
# Target group health check optimizations
|
||||
alb.ingress.kubernetes.io/target-group-attributes: |
|
||||
deregistration_delay.timeout_seconds=30,
|
||||
stickiness.enabled=false,
|
||||
stickiness.type=lb_cookie,
|
||||
stickiness.lb_cookie.duration_seconds=86400,
|
||||
load_balancing.algorithm.type=least_outstanding_requests,
|
||||
target_group_health.dns_failover.minimum_healthy_targets.count=1,
|
||||
target_group_health.dns_failover.minimum_healthy_targets.percentage=off
|
||||
# Health check configuration
|
||||
alb.ingress.kubernetes.io/healthcheck-interval-seconds: "15"
|
||||
alb.ingress.kubernetes.io/healthcheck-timeout-seconds: "5"
|
||||
alb.ingress.kubernetes.io/healthy-threshold-count: "2"
|
||||
alb.ingress.kubernetes.io/unhealthy-threshold-count: "3"
|
||||
alb.ingress.kubernetes.io/success-codes: "200"
|
||||
# Backend protocol and port
|
||||
alb.ingress.kubernetes.io/backend-protocol: HTTP
|
||||
alb.ingress.kubernetes.io/backend-protocol-version: HTTP1
|
||||
# Connection draining
|
||||
alb.ingress.kubernetes.io/actions.ssl-redirect: |
|
||||
{
|
||||
"Type": "redirect",
|
||||
"RedirectConfig": {
|
||||
"Protocol": "HTTPS",
|
||||
"Port": "443",
|
||||
"StatusCode": "HTTP_301"
|
||||
}
|
||||
}
|
||||
enabled: true
|
||||
hosts:
|
||||
- host: app.k8s.formbricks.com
|
||||
@@ -166,3 +242,16 @@ postgresql:
|
||||
enabled: false
|
||||
redis:
|
||||
enabled: false
|
||||
|
||||
## Service Configuration
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
targetPort: 3000
|
||||
annotations:
|
||||
# Service annotations for better ALB integration
|
||||
service.beta.kubernetes.io/aws-load-balancer-backend-protocol: http
|
||||
service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout: "120"
|
||||
service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"
|
||||
# Session affinity disabled for better load distribution
|
||||
sessionAffinity: None
|
||||
|
||||
@@ -57,6 +57,62 @@ locals {
|
||||
LoadBalancer = local.alb_id
|
||||
}
|
||||
}
|
||||
ALB_HTTPCode_ELB_502_Count = {
|
||||
alarm_description = "ALB 502 errors indicating backend connection issues"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 3
|
||||
threshold = 20
|
||||
period = 300
|
||||
unit = "Count"
|
||||
namespace = "AWS/ApplicationELB"
|
||||
metric_name = "HTTPCode_ELB_502_Count"
|
||||
statistic = "Sum"
|
||||
dimensions = {
|
||||
LoadBalancer = local.alb_id
|
||||
}
|
||||
}
|
||||
ALB_HTTPCode_ELB_504_Count = {
|
||||
alarm_description = "ALB 504 errors indicating timeout issues"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 3
|
||||
threshold = 15
|
||||
period = 300
|
||||
unit = "Count"
|
||||
namespace = "AWS/ApplicationELB"
|
||||
metric_name = "HTTPCode_ELB_504_Count"
|
||||
statistic = "Sum"
|
||||
dimensions = {
|
||||
LoadBalancer = local.alb_id
|
||||
}
|
||||
}
|
||||
ALB_HTTPCode_Target_4XX_Count = {
|
||||
alarm_description = "High 4XX error rate indicating client issues or misconfigurations"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 100
|
||||
period = 600
|
||||
unit = "Count"
|
||||
namespace = "AWS/ApplicationELB"
|
||||
metric_name = "HTTPCode_Target_4XX_Count"
|
||||
statistic = "Sum"
|
||||
dimensions = {
|
||||
LoadBalancer = local.alb_id
|
||||
}
|
||||
}
|
||||
ALB_TargetConnectionErrorCount = {
|
||||
alarm_description = "High target connection errors indicating backend connectivity issues"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 3
|
||||
threshold = 50
|
||||
period = 300
|
||||
unit = "Count"
|
||||
namespace = "AWS/ApplicationELB"
|
||||
metric_name = "TargetConnectionErrorCount"
|
||||
statistic = "Sum"
|
||||
dimensions = {
|
||||
LoadBalancer = local.alb_id
|
||||
}
|
||||
}
|
||||
ALB_TargetResponseTime = {
|
||||
alarm_description = format("Average API response time is greater than %s", 5)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
|
||||
@@ -385,6 +385,38 @@ resource "kubernetes_manifest" "node_pool" {
|
||||
values = ["nitro"]
|
||||
}
|
||||
]
|
||||
# Add node startup and shutdown taints to prevent traffic during lifecycle events
|
||||
startupTaints = [
|
||||
{
|
||||
key = "karpenter.sh/startup"
|
||||
value = "true"
|
||||
effect = "NoSchedule"
|
||||
}
|
||||
]
|
||||
# Add kubelet configuration for better pod lifecycle management
|
||||
kubelet = {
|
||||
maxPods = 110
|
||||
clusterDNS = ["169.254.20.10"]
|
||||
# Graceful node shutdown configuration
|
||||
shutdownGracePeriod = "30s"
|
||||
shutdownGracePeriodCriticalPods = "10s"
|
||||
# Pod eviction settings
|
||||
evictionHard = {
|
||||
"memory.available" = "100Mi"
|
||||
"nodefs.available" = "10%"
|
||||
"imagefs.available" = "10%"
|
||||
}
|
||||
evictionSoft = {
|
||||
"memory.available" = "500Mi"
|
||||
"nodefs.available" = "15%"
|
||||
"imagefs.available" = "15%"
|
||||
}
|
||||
evictionSoftGracePeriod = {
|
||||
"memory.available" = "2m"
|
||||
"nodefs.available" = "2m"
|
||||
"imagefs.available" = "2m"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
limits = {
|
||||
@@ -392,8 +424,12 @@ resource "kubernetes_manifest" "node_pool" {
|
||||
}
|
||||
disruption = {
|
||||
consolidationPolicy = "WhenEmptyOrUnderutilized"
|
||||
consolidateAfter = "30s"
|
||||
consolidateAfter = "60s" # Increased from 30s to reduce frequent disruptions
|
||||
# Expiration settings for better predictability
|
||||
expireAfter = "168h" # 7 days
|
||||
}
|
||||
# Weight for prioritizing this NodePool
|
||||
weight = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable import/no-default-export -- required for default export*/
|
||||
import { CommandQueue } from "@/lib/common/command-queue";
|
||||
import { CommandQueue, CommandType } from "@/lib/common/command-queue";
|
||||
import * as Setup from "@/lib/common/setup";
|
||||
import { getIsDebug } from "@/lib/common/utils";
|
||||
import * as Action from "@/lib/survey/action";
|
||||
@@ -9,7 +9,7 @@ import * as User from "@/lib/user/user";
|
||||
import { type TConfigInput, type TLegacyConfigInput } from "@/types/config";
|
||||
import { type TTrackProperties } from "@/types/survey";
|
||||
|
||||
const queue = new CommandQueue();
|
||||
const queue = CommandQueue.getInstance();
|
||||
|
||||
const setup = async (setupConfig: TConfigInput): Promise<void> => {
|
||||
// If the initConfig has a userId or attributes, we need to use the legacy init
|
||||
@@ -27,45 +27,41 @@ const setup = async (setupConfig: TConfigInput): Promise<void> => {
|
||||
// eslint-disable-next-line no-console -- legacy init
|
||||
console.warn("🧱 Formbricks - Warning: Using legacy init");
|
||||
}
|
||||
queue.add(Setup.setup, false, {
|
||||
await queue.add(Setup.setup, CommandType.Setup, false, {
|
||||
...setupConfig,
|
||||
// @ts-expect-error -- apiHost was in the older type
|
||||
...(setupConfig.apiHost && { appUrl: setupConfig.apiHost as string }),
|
||||
} as unknown as TConfigInput);
|
||||
} else {
|
||||
queue.add(Setup.setup, false, setupConfig);
|
||||
await queue.wait();
|
||||
await queue.add(Setup.setup, CommandType.Setup, false, setupConfig);
|
||||
}
|
||||
|
||||
// wait for setup to complete
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
const setUserId = async (userId: string): Promise<void> => {
|
||||
queue.add(User.setUserId, true, userId);
|
||||
await queue.wait();
|
||||
await queue.add(User.setUserId, CommandType.UserAction, true, userId);
|
||||
};
|
||||
|
||||
const setEmail = async (email: string): Promise<void> => {
|
||||
await setAttribute("email", email);
|
||||
await queue.wait();
|
||||
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { email });
|
||||
};
|
||||
|
||||
const setAttribute = async (key: string, value: string): Promise<void> => {
|
||||
queue.add(Attribute.setAttributes, true, { [key]: value });
|
||||
await queue.wait();
|
||||
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { [key]: value });
|
||||
};
|
||||
|
||||
const setAttributes = async (attributes: Record<string, string>): Promise<void> => {
|
||||
queue.add(Attribute.setAttributes, true, attributes);
|
||||
await queue.wait();
|
||||
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, attributes);
|
||||
};
|
||||
|
||||
const setLanguage = async (language: string): Promise<void> => {
|
||||
queue.add(Attribute.setAttributes, true, { language });
|
||||
await queue.wait();
|
||||
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { language });
|
||||
};
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
queue.add(User.logout, true);
|
||||
await queue.wait();
|
||||
await queue.add(User.logout, CommandType.GeneralAction);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -73,13 +69,11 @@ const logout = async (): Promise<void> => {
|
||||
* @param properties - Optional properties to set, like the hidden fields (deprecated, hidden fields will be removed in a future version)
|
||||
*/
|
||||
const track = async (code: string, properties?: TTrackProperties): Promise<void> => {
|
||||
queue.add<string | TTrackProperties | undefined>(Action.trackCodeAction, true, code, properties);
|
||||
await queue.wait();
|
||||
await queue.add(Action.trackCodeAction, CommandType.GeneralAction, true, code, properties);
|
||||
};
|
||||
|
||||
const registerRouteChange = async (): Promise<void> => {
|
||||
queue.add(checkPageUrl, true);
|
||||
await queue.wait();
|
||||
await queue.add(checkPageUrl, CommandType.GeneralAction);
|
||||
};
|
||||
|
||||
const formbricks = {
|
||||
|
||||
@@ -1,32 +1,68 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- required for command queue */
|
||||
/* eslint-disable no-console -- we need to log global errors */
|
||||
import { checkSetup } from "@/lib/common/setup";
|
||||
import { checkSetup } from "@/lib/common/status";
|
||||
import { wrapThrowsAsync } from "@/lib/common/utils";
|
||||
import type { Result } from "@/types/error";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type Result } from "@/types/error";
|
||||
|
||||
export type TCommand = (
|
||||
...args: any[]
|
||||
) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>;
|
||||
|
||||
export enum CommandType {
|
||||
Setup,
|
||||
UserAction,
|
||||
GeneralAction,
|
||||
}
|
||||
|
||||
interface InternalQueueItem {
|
||||
command: TCommand;
|
||||
type: CommandType;
|
||||
checkSetup: boolean;
|
||||
commandArgs: any[];
|
||||
}
|
||||
|
||||
export class CommandQueue {
|
||||
private queue: {
|
||||
command: TCommand;
|
||||
checkSetup: boolean;
|
||||
commandArgs: any[];
|
||||
}[] = [];
|
||||
private queue: InternalQueueItem[] = [];
|
||||
private running = false;
|
||||
private resolvePromise: (() => void) | null = null;
|
||||
private commandPromise: Promise<void> | null = null;
|
||||
private static instance: CommandQueue | null = null;
|
||||
|
||||
public add<A>(command: TCommand, shouldCheckSetup = true, ...args: A[]): void {
|
||||
this.queue.push({ command, checkSetup: shouldCheckSetup, commandArgs: args });
|
||||
public static getInstance(): CommandQueue {
|
||||
CommandQueue.instance ??= new CommandQueue();
|
||||
return CommandQueue.instance;
|
||||
}
|
||||
|
||||
if (!this.running) {
|
||||
this.commandPromise = new Promise((resolve) => {
|
||||
this.resolvePromise = resolve;
|
||||
void this.run();
|
||||
});
|
||||
}
|
||||
public add(
|
||||
command: TCommand,
|
||||
type: CommandType,
|
||||
shouldCheckSetupFlag = true,
|
||||
...args: any[]
|
||||
): Promise<Result<void, unknown>> {
|
||||
return new Promise((addResolve) => {
|
||||
try {
|
||||
const newItem: InternalQueueItem = {
|
||||
command,
|
||||
type,
|
||||
checkSetup: shouldCheckSetupFlag,
|
||||
commandArgs: args,
|
||||
};
|
||||
|
||||
this.queue.push(newItem);
|
||||
|
||||
if (!this.running) {
|
||||
this.commandPromise = new Promise((resolve) => {
|
||||
this.resolvePromise = resolve;
|
||||
void this.run();
|
||||
});
|
||||
}
|
||||
|
||||
addResolve({ ok: true, data: undefined });
|
||||
} catch (error) {
|
||||
addResolve({ ok: false, error: error as Error });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async wait(): Promise<void> {
|
||||
@@ -37,21 +73,29 @@ export class CommandQueue {
|
||||
|
||||
private async run(): Promise<void> {
|
||||
this.running = true;
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
const currentItem = this.queue.shift();
|
||||
|
||||
if (!currentItem) continue;
|
||||
|
||||
// make sure formbricks is setup
|
||||
if (currentItem.checkSetup) {
|
||||
// call different function based on package type
|
||||
const setupResult = checkSetup();
|
||||
|
||||
if (!setupResult.ok) {
|
||||
console.warn(`🧱 Formbricks - Setup not complete.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentItem.type === CommandType.GeneralAction) {
|
||||
// first check if there are pending updates in the update queue
|
||||
const updateQueue = UpdateQueue.getInstance();
|
||||
if (!updateQueue.isEmpty()) {
|
||||
console.log("🧱 Formbricks - Waiting for pending updates to complete before executing command");
|
||||
await updateQueue.processUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
const executeCommand = async (): Promise<Result<void, unknown>> => {
|
||||
return (await currentItem.command.apply(null, currentItem.commandArgs)) as Result<void, unknown>;
|
||||
};
|
||||
@@ -64,6 +108,7 @@ export class CommandQueue {
|
||||
console.error("🧱 Formbricks - Global error: ", result.data.error);
|
||||
}
|
||||
}
|
||||
|
||||
this.running = false;
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise();
|
||||
|
||||
@@ -16,10 +16,7 @@ export class Config {
|
||||
}
|
||||
|
||||
static getInstance(): Config {
|
||||
if (!Config.instance) {
|
||||
Config.instance = new Config();
|
||||
}
|
||||
|
||||
Config.instance ??= new Config();
|
||||
return Config.instance;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
/* eslint-disable no-console -- required for logging */
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { JS_LOCAL_STORAGE_KEY } from "@/lib/common/constants";
|
||||
import {
|
||||
addCleanupEventListeners,
|
||||
addEventListeners,
|
||||
removeAllEventListeners,
|
||||
} from "@/lib/common/event-listeners";
|
||||
import { addCleanupEventListeners, addEventListeners } from "@/lib/common/event-listeners";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { getIsSetup, setIsSetup } from "@/lib/common/status";
|
||||
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
|
||||
import { fetchEnvironmentState } from "@/lib/environment/state";
|
||||
import { checkPageUrl } from "@/lib/survey/no-code-action";
|
||||
@@ -24,18 +21,11 @@ import {
|
||||
type MissingFieldError,
|
||||
type MissingPersonError,
|
||||
type NetworkError,
|
||||
type NotSetupError,
|
||||
type Result,
|
||||
err,
|
||||
okVoid,
|
||||
} from "@/types/error";
|
||||
|
||||
let isSetup = false;
|
||||
|
||||
export const setIsSetup = (state: boolean): void => {
|
||||
isSetup = state;
|
||||
};
|
||||
|
||||
const migrateLocalStorage = (): { changed: boolean; newState?: TConfig } => {
|
||||
const existingConfig = localStorage.getItem(JS_LOCAL_STORAGE_KEY);
|
||||
|
||||
@@ -99,7 +89,7 @@ export const setup = async (
|
||||
}
|
||||
}
|
||||
|
||||
if (isSetup) {
|
||||
if (getIsSetup()) {
|
||||
logger.debug("Already set up, skipping setup.");
|
||||
return okVoid();
|
||||
}
|
||||
@@ -193,6 +183,7 @@ export const setup = async (
|
||||
|
||||
if (environmentStateResponse.ok) {
|
||||
environmentState = environmentStateResponse.data;
|
||||
logger.debug(`Fetched ${environmentState.data.surveys.length.toString()} surveys from the backend`);
|
||||
} else {
|
||||
logger.error(
|
||||
`Error fetching environment state: ${environmentStateResponse.error.code} - ${environmentStateResponse.error.responseMessage ?? ""}`
|
||||
@@ -257,7 +248,9 @@ export const setup = async (
|
||||
});
|
||||
|
||||
const surveyNames = filteredSurveys.map((s) => s.name);
|
||||
logger.debug(`Fetched ${surveyNames.length.toString()} surveys during sync: ${surveyNames.join(", ")}`);
|
||||
logger.debug(
|
||||
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
|
||||
);
|
||||
} catch {
|
||||
logger.debug("Error during sync. Please try again.");
|
||||
}
|
||||
@@ -303,6 +296,7 @@ export const setup = async (
|
||||
}
|
||||
|
||||
const environmentState = environmentStateResponse.data;
|
||||
logger.debug(`Fetched ${environmentState.data.surveys.length.toString()} surveys from the backend`);
|
||||
const filteredSurveys = filterSurveys(environmentState, userState);
|
||||
|
||||
config.update({
|
||||
@@ -312,6 +306,11 @@ export const setup = async (
|
||||
environment: environmentState,
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
const surveyNames = filteredSurveys.map((s) => s.name);
|
||||
logger.debug(
|
||||
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
|
||||
);
|
||||
} catch (e) {
|
||||
await handleErrorOnFirstSetup(e as { code: string; responseMessage: string });
|
||||
}
|
||||
@@ -329,35 +328,26 @@ export const setup = async (
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const checkSetup = (): Result<void, NotSetupError> => {
|
||||
const logger = Logger.getInstance();
|
||||
logger.debug("Check if set up");
|
||||
|
||||
if (!isSetup) {
|
||||
return err({
|
||||
code: "not_setup",
|
||||
message: "Formbricks is not set up. Call setup() first.",
|
||||
});
|
||||
}
|
||||
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const tearDown = (): void => {
|
||||
const logger = Logger.getInstance();
|
||||
const appConfig = Config.getInstance();
|
||||
|
||||
const { environment } = appConfig.get();
|
||||
const filteredSurveys = filterSurveys(environment, DEFAULT_USER_STATE_NO_USER_ID);
|
||||
|
||||
logger.debug("Setting user state to default");
|
||||
|
||||
// clear the user state and set it to the default value
|
||||
appConfig.update({
|
||||
...appConfig.get(),
|
||||
user: DEFAULT_USER_STATE_NO_USER_ID,
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
// remove container element from DOM
|
||||
removeWidgetContainer();
|
||||
addWidgetContainer();
|
||||
setIsSurveyRunning(false);
|
||||
removeAllEventListeners();
|
||||
setIsSetup(false);
|
||||
};
|
||||
|
||||
export const handleErrorOnFirstSetup = (e: { code: string; responseMessage: string }): Promise<never> => {
|
||||
|
||||
26
packages/js-core/src/lib/common/status.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { type NotSetupError, type Result, err, okVoid } from "@/types/error";
|
||||
|
||||
let isSetup = false;
|
||||
|
||||
export const setIsSetup = (state: boolean): void => {
|
||||
isSetup = state;
|
||||
};
|
||||
|
||||
export const getIsSetup = (): boolean => {
|
||||
return isSetup;
|
||||
};
|
||||
|
||||
export const checkSetup = (): Result<void, NotSetupError> => {
|
||||
const logger = Logger.getInstance();
|
||||
logger.debug("Check if set up");
|
||||
|
||||
if (!isSetup) {
|
||||
return err({
|
||||
code: "not_setup",
|
||||
message: "Formbricks is not set up. Call setup() first.",
|
||||
});
|
||||
}
|
||||
|
||||
return okVoid();
|
||||
};
|
||||
@@ -1,13 +1,24 @@
|
||||
import { CommandQueue } from "@/lib/common/command-queue";
|
||||
import { checkSetup } from "@/lib/common/setup";
|
||||
import { CommandQueue, CommandType } from "@/lib/common/command-queue";
|
||||
import { checkSetup } from "@/lib/common/status";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type Result } from "@/types/error";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock the setup module so we can control checkSetup()
|
||||
vi.mock("@/lib/common/setup", () => ({
|
||||
vi.mock("@/lib/common/status", () => ({
|
||||
checkSetup: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the UpdateQueue
|
||||
vi.mock("@/lib/user/update-queue", () => ({
|
||||
UpdateQueue: {
|
||||
getInstance: vi.fn(() => ({
|
||||
isEmpty: vi.fn(),
|
||||
processUpdates: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("CommandQueue", () => {
|
||||
let queue: CommandQueue;
|
||||
|
||||
@@ -51,9 +62,9 @@ describe("CommandQueue", () => {
|
||||
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
|
||||
|
||||
// Enqueue commands
|
||||
queue.add(cmdA, true);
|
||||
queue.add(cmdB, true);
|
||||
queue.add(cmdC, true);
|
||||
await queue.add(cmdA, CommandType.GeneralAction, true);
|
||||
await queue.add(cmdB, CommandType.GeneralAction, true);
|
||||
await queue.add(cmdC, CommandType.GeneralAction, true);
|
||||
|
||||
// Wait for them to finish
|
||||
await queue.wait();
|
||||
@@ -79,7 +90,7 @@ describe("CommandQueue", () => {
|
||||
},
|
||||
});
|
||||
|
||||
queue.add(cmd, true);
|
||||
await queue.add(cmd, CommandType.GeneralAction, true);
|
||||
await queue.wait();
|
||||
|
||||
// Command should never have been called
|
||||
@@ -99,7 +110,7 @@ describe("CommandQueue", () => {
|
||||
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
|
||||
|
||||
// Here we pass 'false' for the second argument, so no check is performed
|
||||
queue.add(cmd, false);
|
||||
await queue.add(cmd, CommandType.GeneralAction, false);
|
||||
await queue.wait();
|
||||
|
||||
expect(cmd).toHaveBeenCalledTimes(1);
|
||||
@@ -128,7 +139,7 @@ describe("CommandQueue", () => {
|
||||
throw new Error("some error");
|
||||
});
|
||||
|
||||
queue.add(failingCmd, true);
|
||||
await queue.add(failingCmd, CommandType.GeneralAction, true);
|
||||
await queue.wait();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("🧱 Formbricks - Global error: ", expect.any(Error));
|
||||
@@ -153,8 +164,8 @@ describe("CommandQueue", () => {
|
||||
|
||||
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
|
||||
|
||||
queue.add(cmd1, true);
|
||||
queue.add(cmd2, true);
|
||||
await queue.add(cmd1, CommandType.GeneralAction, true);
|
||||
await queue.add(cmd2, CommandType.GeneralAction, true);
|
||||
|
||||
await queue.wait();
|
||||
|
||||
@@ -162,4 +173,70 @@ describe("CommandQueue", () => {
|
||||
expect(cmd1).toHaveBeenCalled();
|
||||
expect(cmd2).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("processes UpdateQueue before executing GeneralAction commands", async () => {
|
||||
const mockUpdateQueue = {
|
||||
isEmpty: vi.fn().mockReturnValue(false),
|
||||
processUpdates: vi.fn().mockResolvedValue("test"),
|
||||
};
|
||||
|
||||
const mockUpdateQueueInstance = vi.spyOn(UpdateQueue, "getInstance");
|
||||
mockUpdateQueueInstance.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue);
|
||||
|
||||
const generalActionCmd = vi.fn((): Promise<Result<void, unknown>> => {
|
||||
return Promise.resolve({ ok: true, data: undefined });
|
||||
});
|
||||
|
||||
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
|
||||
|
||||
await queue.add(generalActionCmd, CommandType.GeneralAction, true);
|
||||
await queue.wait();
|
||||
|
||||
expect(mockUpdateQueue.isEmpty).toHaveBeenCalled();
|
||||
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
|
||||
expect(generalActionCmd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("implements singleton pattern correctly", () => {
|
||||
const instance1 = CommandQueue.getInstance();
|
||||
const instance2 = CommandQueue.getInstance();
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
test("handles multiple commands with different types and setup checks", async () => {
|
||||
const executionOrder: string[] = [];
|
||||
|
||||
const cmd1 = vi.fn((): Promise<Result<void, unknown>> => {
|
||||
executionOrder.push("cmd1");
|
||||
return Promise.resolve({ ok: true, data: undefined });
|
||||
});
|
||||
|
||||
const cmd2 = vi.fn((): Promise<Result<void, unknown>> => {
|
||||
executionOrder.push("cmd2");
|
||||
return Promise.resolve({ ok: true, data: undefined });
|
||||
});
|
||||
|
||||
const cmd3 = vi.fn((): Promise<Result<void, unknown>> => {
|
||||
executionOrder.push("cmd3");
|
||||
return Promise.resolve({ ok: true, data: undefined });
|
||||
});
|
||||
|
||||
// Setup check will fail for cmd2
|
||||
vi.mocked(checkSetup)
|
||||
.mockReturnValueOnce({ ok: true, data: undefined }) // for cmd1
|
||||
.mockReturnValueOnce({ ok: false, error: { code: "not_setup", message: "Not setup" } }) // for cmd2
|
||||
.mockReturnValueOnce({ ok: true, data: undefined }); // for cmd3
|
||||
|
||||
await queue.add(cmd1, CommandType.Setup, true);
|
||||
await queue.add(cmd2, CommandType.UserAction, true);
|
||||
await queue.add(cmd3, CommandType.GeneralAction, true);
|
||||
|
||||
await queue.wait();
|
||||
|
||||
// cmd2 should be skipped due to failed setup check
|
||||
expect(executionOrder).toEqual(["cmd1", "cmd3"]);
|
||||
expect(cmd1).toHaveBeenCalled();
|
||||
expect(cmd2).not.toHaveBeenCalled();
|
||||
expect(cmd3).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method -- required for testing */
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { JS_LOCAL_STORAGE_KEY } from "@/lib/common/constants";
|
||||
import {
|
||||
addCleanupEventListeners,
|
||||
addEventListeners,
|
||||
removeAllEventListeners,
|
||||
} from "@/lib/common/event-listeners";
|
||||
import { addCleanupEventListeners, addEventListeners } from "@/lib/common/event-listeners";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { checkSetup, handleErrorOnFirstSetup, setIsSetup, setup, tearDown } from "@/lib/common/setup";
|
||||
import { handleErrorOnFirstSetup, setup, tearDown } from "@/lib/common/setup";
|
||||
import { setIsSetup } from "@/lib/common/status";
|
||||
import { filterSurveys, isNowExpired } from "@/lib/common/utils";
|
||||
import { fetchEnvironmentState } from "@/lib/environment/state";
|
||||
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
|
||||
@@ -287,24 +284,8 @@ describe("setup.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSetup()", () => {
|
||||
test("returns err if not setup", () => {
|
||||
const res = checkSetup();
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.error.code).toBe("not_setup");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns ok if setup", () => {
|
||||
setIsSetup(true);
|
||||
const res = checkSetup();
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tearDown()", () => {
|
||||
test("resets user state to default and removes event listeners", () => {
|
||||
test("resets user state to default", () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
user: { data: { userId: "XYZ" } },
|
||||
@@ -321,7 +302,7 @@ describe("setup.ts", () => {
|
||||
user: DEFAULT_USER_STATE_NO_USER_ID,
|
||||
})
|
||||
);
|
||||
expect(removeAllEventListeners).toHaveBeenCalled();
|
||||
expect(filterSurveys).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
41
packages/js-core/src/lib/common/tests/status.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { checkSetup, getIsSetup, setIsSetup } from "@/lib/common/status";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
describe("checkSetup()", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setIsSetup(false);
|
||||
});
|
||||
|
||||
test("returns err if not setup", () => {
|
||||
const res = checkSetup();
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.error.code).toBe("not_setup");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns ok if setup", () => {
|
||||
setIsSetup(true);
|
||||
const res = checkSetup();
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsSetup()", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setIsSetup(false);
|
||||
});
|
||||
|
||||
test("returns false if not setup", () => {
|
||||
const res = getIsSetup();
|
||||
expect(res).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true if setup", () => {
|
||||
setIsSetup(true);
|
||||
const res = getIsSetup();
|
||||
expect(res).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
getDefaultLanguageCode,
|
||||
getIsDebug,
|
||||
getLanguageCode,
|
||||
getSecureRandom,
|
||||
getStyling,
|
||||
handleHiddenFields,
|
||||
handleUrlFilters,
|
||||
isNowExpired,
|
||||
shouldDisplayBasedOnPercentage,
|
||||
@@ -23,7 +25,7 @@ import type {
|
||||
TSurveyStyling,
|
||||
TUserState,
|
||||
} from "@/types/config";
|
||||
import { type TActionClassPageUrlRule } from "@/types/survey";
|
||||
import { type TActionClassNoCodeConfig, type TActionClassPageUrlRule } from "@/types/survey";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockSurveyId1 = "e3kxlpnzmdp84op9qzxl9olj";
|
||||
@@ -61,7 +63,49 @@ describe("utils.ts", () => {
|
||||
test("returns ok on success", () => {
|
||||
const fn = vi.fn(() => "success");
|
||||
const wrapped = wrapThrows(fn);
|
||||
expect(wrapped()).toEqual({ ok: true, data: "success" });
|
||||
const result = wrapped();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBe("success");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns err on error", () => {
|
||||
const fn = vi.fn(() => {
|
||||
throw new Error("Something broke");
|
||||
});
|
||||
const wrapped = wrapThrows(fn);
|
||||
const result = wrapped();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe("Something broke");
|
||||
}
|
||||
});
|
||||
|
||||
test("passes arguments to wrapped function", () => {
|
||||
const fn = vi.fn((a: number, b: number) => a + b);
|
||||
const wrapped = wrapThrows(fn);
|
||||
const result = wrapped(2, 3);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBe(5);
|
||||
}
|
||||
expect(fn).toHaveBeenCalledWith(2, 3);
|
||||
});
|
||||
|
||||
test("handles async function", () => {
|
||||
const fn = vi.fn(async () => {
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, 10);
|
||||
});
|
||||
return "async success";
|
||||
});
|
||||
const wrapped = wrapThrows(fn);
|
||||
const result = wrapped();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeInstanceOf(Promise);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -561,6 +605,55 @@ describe("utils.ts", () => {
|
||||
const result = handleUrlFilters(urlFilters);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true if urlFilters is empty", () => {
|
||||
const urlFilters: TActionClassNoCodeConfig["urlFilters"] = [];
|
||||
|
||||
const result = handleUrlFilters(urlFilters);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false if no urlFilters match", () => {
|
||||
const urlFilters = [
|
||||
{
|
||||
value: "https://example.com/other",
|
||||
rule: "exactMatch" as unknown as TActionClassPageUrlRule,
|
||||
},
|
||||
];
|
||||
|
||||
// mock window.location.href
|
||||
vi.stubGlobal("window", {
|
||||
location: {
|
||||
href: "https://example.com/path",
|
||||
},
|
||||
});
|
||||
|
||||
const result = handleUrlFilters(urlFilters);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true if any urlFilter matches", () => {
|
||||
const urlFilters = [
|
||||
{
|
||||
value: "https://example.com/other",
|
||||
rule: "exactMatch" as unknown as TActionClassPageUrlRule,
|
||||
},
|
||||
{
|
||||
value: "path",
|
||||
rule: "contains" as unknown as TActionClassPageUrlRule,
|
||||
},
|
||||
];
|
||||
|
||||
// mock window.location.href
|
||||
vi.stubGlobal("window", {
|
||||
location: {
|
||||
href: "https://example.com/path",
|
||||
},
|
||||
});
|
||||
|
||||
const result = handleUrlFilters(urlFilters);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
@@ -571,12 +664,12 @@ describe("utils.ts", () => {
|
||||
const targetElement = document.createElement("div");
|
||||
|
||||
const action: TEnvironmentStateActionClass = {
|
||||
id: "clabc123abc", // some valid cuid2 or placeholder
|
||||
id: "clabc123abc",
|
||||
name: "Test Action",
|
||||
type: "noCode", // or "code", but here we have noCode
|
||||
type: "noCode",
|
||||
key: null,
|
||||
noCodeConfig: {
|
||||
type: "pageView", // the mismatch
|
||||
type: "pageView",
|
||||
urlFilters: [],
|
||||
},
|
||||
};
|
||||
@@ -590,7 +683,7 @@ describe("utils.ts", () => {
|
||||
targetElement.innerHTML = "Test";
|
||||
|
||||
const action: TEnvironmentStateActionClass = {
|
||||
id: "clabc123abc", // some valid cuid2 or placeholder
|
||||
id: "clabc123abc",
|
||||
name: "Test Action",
|
||||
type: "noCode",
|
||||
key: null,
|
||||
@@ -615,7 +708,7 @@ describe("utils.ts", () => {
|
||||
targetElement.matches = vi.fn(() => true);
|
||||
|
||||
const action: TEnvironmentStateActionClass = {
|
||||
id: "clabc123abc", // some valid cuid2 or placeholder
|
||||
id: "clabc123abc",
|
||||
name: "Test Action",
|
||||
type: "noCode",
|
||||
key: null,
|
||||
@@ -640,14 +733,35 @@ describe("utils.ts", () => {
|
||||
targetElement.matches = vi.fn(() => false);
|
||||
|
||||
const action: TEnvironmentStateActionClass = {
|
||||
id: "clabc123abc", // some valid cuid2 or placeholder
|
||||
id: "clabc123abc",
|
||||
name: "Test Action",
|
||||
type: "noCode",
|
||||
key: null,
|
||||
noCodeConfig: {
|
||||
type: "click",
|
||||
urlFilters: [],
|
||||
elementSelector: { cssSelector },
|
||||
elementSelector: {
|
||||
cssSelector,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluateNoCodeConfigClick(targetElement, action);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false if neither innerHtml nor cssSelector is provided", () => {
|
||||
const targetElement = document.createElement("div");
|
||||
|
||||
const action: TEnvironmentStateActionClass = {
|
||||
id: "clabc123abc",
|
||||
name: "Test Action",
|
||||
type: "noCode",
|
||||
key: null,
|
||||
noCodeConfig: {
|
||||
type: "click",
|
||||
urlFilters: [],
|
||||
elementSelector: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -657,44 +771,240 @@ describe("utils.ts", () => {
|
||||
|
||||
test("returns false if urlFilters do not match", () => {
|
||||
const targetElement = document.createElement("div");
|
||||
const urlFilters = [
|
||||
{
|
||||
value: "https://example.com/path",
|
||||
rule: "exactMatch" as unknown as TActionClassPageUrlRule,
|
||||
targetElement.innerHTML = "Test";
|
||||
|
||||
// mock window.location.href
|
||||
vi.stubGlobal("window", {
|
||||
location: {
|
||||
href: "https://example.com/path",
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const action: TEnvironmentStateActionClass = {
|
||||
id: "clabc123abc", // some valid cuid2 or placeholder
|
||||
id: "clabc123abc",
|
||||
name: "Test Action",
|
||||
type: "noCode",
|
||||
key: null,
|
||||
noCodeConfig: {
|
||||
type: "click",
|
||||
urlFilters,
|
||||
elementSelector: {},
|
||||
urlFilters: [
|
||||
{
|
||||
value: "https://example.com/other",
|
||||
rule: "exactMatch" as unknown as TActionClassPageUrlRule,
|
||||
},
|
||||
],
|
||||
elementSelector: {
|
||||
innerHtml: "Test",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluateNoCodeConfigClick(targetElement, action);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true if both innerHtml and urlFilters match", () => {
|
||||
const targetElement = document.createElement("div");
|
||||
targetElement.innerHTML = "Test";
|
||||
|
||||
// mock window.location.href
|
||||
vi.stubGlobal("window", {
|
||||
location: {
|
||||
href: "https://example.com/path",
|
||||
},
|
||||
});
|
||||
|
||||
const action: TEnvironmentStateActionClass = {
|
||||
id: "clabc123abc",
|
||||
name: "Test Action",
|
||||
type: "noCode",
|
||||
key: null,
|
||||
noCodeConfig: {
|
||||
type: "click",
|
||||
urlFilters: [
|
||||
{
|
||||
value: "path",
|
||||
rule: "contains" as unknown as TActionClassPageUrlRule,
|
||||
},
|
||||
],
|
||||
elementSelector: {
|
||||
innerHtml: "Test",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluateNoCodeConfigClick(targetElement, action);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("handles multiple cssSelectors correctly", () => {
|
||||
const targetElement = document.createElement("div");
|
||||
targetElement.className = "test other";
|
||||
|
||||
targetElement.matches = vi.fn((selector) => {
|
||||
return selector === ".test" || selector === ".other";
|
||||
});
|
||||
|
||||
const action: TEnvironmentStateActionClass = {
|
||||
id: "clabc123abc",
|
||||
name: "Test Action",
|
||||
type: "noCode",
|
||||
key: null,
|
||||
noCodeConfig: {
|
||||
type: "click",
|
||||
urlFilters: [],
|
||||
elementSelector: {
|
||||
cssSelector: ".test .other",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluateNoCodeConfigClick(targetElement, action);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// getIsDebug
|
||||
// ---------------------------------------------------------------------------------
|
||||
describe("getIsDebug()", () => {
|
||||
test("returns true if debug param is set", () => {
|
||||
// mock window.location.search
|
||||
vi.stubGlobal("window", {
|
||||
location: {
|
||||
search: "?formbricksDebug=true",
|
||||
},
|
||||
beforeEach(() => {
|
||||
// Reset window.location.search before each test
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { search: "" },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
const result = getIsDebug();
|
||||
expect(result).toBe(true);
|
||||
test("returns true if debug parameter is set", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { search: "?formbricksDebug=true" },
|
||||
writable: true,
|
||||
});
|
||||
expect(getIsDebug()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false if debug parameter is not set", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { search: "?otherParam=value" },
|
||||
writable: true,
|
||||
});
|
||||
expect(getIsDebug()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false if search string is empty", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { search: "" },
|
||||
writable: true,
|
||||
});
|
||||
expect(getIsDebug()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false if search string is just '?'", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { search: "?" },
|
||||
writable: true,
|
||||
});
|
||||
expect(getIsDebug()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// handleHiddenFields
|
||||
// ---------------------------------------------------------------------------------
|
||||
describe("handleHiddenFields()", () => {
|
||||
test("returns empty object when hidden fields are not enabled", () => {
|
||||
const hiddenFieldsConfig = {
|
||||
enabled: false,
|
||||
fieldIds: ["field1", "field2"],
|
||||
};
|
||||
const hiddenFields = {
|
||||
field1: "value1",
|
||||
field2: "value2",
|
||||
};
|
||||
|
||||
const result = handleHiddenFields(hiddenFieldsConfig, hiddenFields);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("returns empty object when no hidden fields are provided", () => {
|
||||
const hiddenFieldsConfig = {
|
||||
enabled: true,
|
||||
fieldIds: ["field1", "field2"],
|
||||
};
|
||||
|
||||
const result = handleHiddenFields(hiddenFieldsConfig);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("filters and returns only valid hidden fields", () => {
|
||||
const hiddenFieldsConfig = {
|
||||
enabled: true,
|
||||
fieldIds: ["field1", "field2"],
|
||||
};
|
||||
const hiddenFields = {
|
||||
field1: "value1",
|
||||
field2: "value2",
|
||||
field3: "value3", // This should be filtered out
|
||||
};
|
||||
|
||||
const result = handleHiddenFields(hiddenFieldsConfig, hiddenFields);
|
||||
expect(result).toEqual({
|
||||
field1: "value1",
|
||||
field2: "value2",
|
||||
});
|
||||
});
|
||||
|
||||
test("handles empty fieldIds array", () => {
|
||||
const hiddenFieldsConfig = {
|
||||
enabled: true,
|
||||
fieldIds: [],
|
||||
};
|
||||
const hiddenFields = {
|
||||
field1: "value1",
|
||||
field2: "value2",
|
||||
};
|
||||
|
||||
const result = handleHiddenFields(hiddenFieldsConfig, hiddenFields);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("handles null fieldIds", () => {
|
||||
const hiddenFieldsConfig = {
|
||||
enabled: true,
|
||||
fieldIds: undefined,
|
||||
};
|
||||
const hiddenFields = {
|
||||
field1: "value1",
|
||||
field2: "value2",
|
||||
};
|
||||
|
||||
const result = handleHiddenFields(hiddenFieldsConfig, hiddenFields);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// getSecureRandom
|
||||
// ---------------------------------------------------------------------------------
|
||||
describe("getSecureRandom()", () => {
|
||||
test("returns a number between 0 and 1", () => {
|
||||
const result = getSecureRandom();
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
expect(result).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("returns different values on subsequent calls", () => {
|
||||
const result1 = getSecureRandom();
|
||||
const result2 = getSecureRandom();
|
||||
expect(result1).not.toBe(result2);
|
||||
});
|
||||
|
||||
test("uses crypto.getRandomValues", () => {
|
||||
const mockGetRandomValues = vi.spyOn(crypto, "getRandomValues");
|
||||
getSecureRandom();
|
||||
expect(mockGetRandomValues).toHaveBeenCalled();
|
||||
mockGetRandomValues.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,7 +121,11 @@ export const filterSurveys = (
|
||||
});
|
||||
|
||||
if (!userId) {
|
||||
return filteredSurveys;
|
||||
// exclude surveys that have a segment with filters
|
||||
return filteredSurveys.filter((survey) => {
|
||||
const segmentFiltersLength = survey.segment?.filters.length ?? 0;
|
||||
return segmentFiltersLength === 0;
|
||||
});
|
||||
}
|
||||
|
||||
if (!segments.length) {
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
/* eslint-disable no-console -- required for logging */
|
||||
import { CommandQueue, CommandType } from "@/lib/common/command-queue";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { TimeoutStack } from "@/lib/common/timeout-stack";
|
||||
import { evaluateNoCodeConfigClick, handleUrlFilters } from "@/lib/common/utils";
|
||||
import { trackNoCodeAction } from "@/lib/survey/action";
|
||||
import { setIsSurveyRunning } from "@/lib/survey/widget";
|
||||
import { type TEnvironmentStateActionClass } from "@/types/config";
|
||||
import { type NetworkError, type Result, type ResultError, err, match, okVoid } from "@/types/error";
|
||||
import { type Result } from "@/types/error";
|
||||
|
||||
// Factory for creating context-specific tracking handlers
|
||||
export const createTrackNoCodeActionWithContext = (context: string) => {
|
||||
return async (actionName: string): Promise<Result<void, unknown>> => {
|
||||
const result = await trackNoCodeAction(actionName);
|
||||
if (!result.ok) {
|
||||
const errorToLog = result.error as { message?: string };
|
||||
const errorMessageText = errorToLog.message ?? "An unknown error occurred.";
|
||||
console.error(
|
||||
`🧱 Formbricks - Error in no-code ${context} action '${actionName}': ${errorMessageText}`,
|
||||
errorToLog
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
const trackNoCodePageViewActionHandler = createTrackNoCodeActionWithContext("page view");
|
||||
const trackNoCodeClickActionHandler = createTrackNoCodeActionWithContext("click");
|
||||
const trackNoCodeExitIntentActionHandler = createTrackNoCodeActionWithContext("exit intent");
|
||||
const trackNoCodeScrollActionHandler = createTrackNoCodeActionWithContext("scroll");
|
||||
|
||||
// Event types for various listeners
|
||||
const events = ["hashchange", "popstate", "pushstate", "replacestate", "load"];
|
||||
@@ -18,7 +39,8 @@ export const setIsHistoryPatched = (value: boolean): void => {
|
||||
isHistoryPatched = value;
|
||||
};
|
||||
|
||||
export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
|
||||
export const checkPageUrl = async (): Promise<Result<void, unknown>> => {
|
||||
const queue = CommandQueue.getInstance();
|
||||
const appConfig = Config.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
const timeoutStack = TimeoutStack.getInstance();
|
||||
@@ -35,11 +57,7 @@ export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
|
||||
const isValidUrl = handleUrlFilters(urlFilters);
|
||||
|
||||
if (isValidUrl) {
|
||||
const trackResult = await trackNoCodeAction(event.name);
|
||||
|
||||
if (!trackResult.ok) {
|
||||
return err(trackResult.error);
|
||||
}
|
||||
await queue.add(trackNoCodePageViewActionHandler, CommandType.GeneralAction, true, event.name);
|
||||
} else {
|
||||
const scheduledTimeouts = timeoutStack.getTimeouts();
|
||||
|
||||
@@ -52,10 +70,12 @@ export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
|
||||
}
|
||||
}
|
||||
|
||||
return okVoid();
|
||||
return { ok: true, data: undefined };
|
||||
};
|
||||
|
||||
const checkPageUrlWrapper = (): ReturnType<typeof checkPageUrl> => checkPageUrl();
|
||||
const checkPageUrlWrapper = (): void => {
|
||||
void checkPageUrl();
|
||||
};
|
||||
|
||||
export const addPageUrlEventListeners = (): void => {
|
||||
if (typeof window === "undefined" || arePageUrlEventListenersAdded) return;
|
||||
@@ -92,7 +112,8 @@ export const removePageUrlEventListeners = (): void => {
|
||||
// Click Event Handlers
|
||||
let isClickEventListenerAdded = false;
|
||||
|
||||
const checkClickMatch = (event: MouseEvent): void => {
|
||||
const checkClickMatch = async (event: MouseEvent): Promise<void> => {
|
||||
const queue = CommandQueue.getInstance();
|
||||
const appConfig = Config.getInstance();
|
||||
|
||||
const { environment } = appConfig.get();
|
||||
@@ -105,28 +126,15 @@ const checkClickMatch = (event: MouseEvent): void => {
|
||||
|
||||
const targetElement = event.target as HTMLElement;
|
||||
|
||||
noCodeClickActionClasses.forEach((action: TEnvironmentStateActionClass) => {
|
||||
for (const action of noCodeClickActionClasses) {
|
||||
if (evaluateNoCodeConfigClick(targetElement, action)) {
|
||||
trackNoCodeAction(action.name)
|
||||
.then((res) => {
|
||||
match(
|
||||
res,
|
||||
(_value: unknown) => undefined,
|
||||
(actionError: unknown) => {
|
||||
// errorHandler.handle(actionError);
|
||||
console.error(actionError);
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
await queue.add(trackNoCodeClickActionHandler, CommandType.GeneralAction, true, action.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const checkClickMatchWrapper = (e: MouseEvent): void => {
|
||||
checkClickMatch(e);
|
||||
void checkClickMatch(e);
|
||||
};
|
||||
|
||||
export const addClickEventListener = (): void => {
|
||||
@@ -144,7 +152,8 @@ export const removeClickEventListener = (): void => {
|
||||
// Exit Intent Handlers
|
||||
let isExitIntentListenerAdded = false;
|
||||
|
||||
const checkExitIntent = async (e: MouseEvent): Promise<ResultError<NetworkError> | undefined> => {
|
||||
const checkExitIntent = async (e: MouseEvent): Promise<void> => {
|
||||
const queue = CommandQueue.getInstance();
|
||||
const appConfig = Config.getInstance();
|
||||
|
||||
const { environment } = appConfig.get();
|
||||
@@ -161,13 +170,14 @@ const checkExitIntent = async (e: MouseEvent): Promise<ResultError<NetworkError>
|
||||
|
||||
if (!isValidUrl) continue;
|
||||
|
||||
const trackResult = await trackNoCodeAction(event.name);
|
||||
if (!trackResult.ok) return err(trackResult.error);
|
||||
await queue.add(trackNoCodeExitIntentActionHandler, CommandType.GeneralAction, true, event.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkExitIntentWrapper = (e: MouseEvent): ReturnType<typeof checkExitIntent> => checkExitIntent(e);
|
||||
const checkExitIntentWrapper = (e: MouseEvent): void => {
|
||||
void checkExitIntent(e);
|
||||
};
|
||||
|
||||
export const addExitIntentListener = (): void => {
|
||||
if (typeof document !== "undefined" && !isExitIntentListenerAdded) {
|
||||
@@ -189,7 +199,8 @@ export const removeExitIntentListener = (): void => {
|
||||
let scrollDepthListenerAdded = false;
|
||||
let scrollDepthTriggered = false;
|
||||
|
||||
const checkScrollDepth = async (): Promise<Result<void, unknown>> => {
|
||||
const checkScrollDepth = async (): Promise<void> => {
|
||||
const queue = CommandQueue.getInstance();
|
||||
const appConfig = Config.getInstance();
|
||||
|
||||
const scrollPosition = window.scrollY;
|
||||
@@ -216,15 +227,14 @@ const checkScrollDepth = async (): Promise<Result<void, unknown>> => {
|
||||
|
||||
if (!isValidUrl) continue;
|
||||
|
||||
const trackResult = await trackNoCodeAction(event.name);
|
||||
if (!trackResult.ok) return err(trackResult.error);
|
||||
await queue.add(trackNoCodeScrollActionHandler, CommandType.GeneralAction, true, event.name);
|
||||
}
|
||||
}
|
||||
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
const checkScrollDepthWrapper = (): ReturnType<typeof checkScrollDepth> => checkScrollDepth();
|
||||
const checkScrollDepthWrapper = (): void => {
|
||||
void checkScrollDepth();
|
||||
};
|
||||
|
||||
export const addScrollDepthListener = (): void => {
|
||||
if (typeof window !== "undefined" && !scrollDepthListenerAdded) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method -- mock functions are unbound */
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { checkSetup } from "@/lib/common/status";
|
||||
import { TimeoutStack } from "@/lib/common/timeout-stack";
|
||||
import { handleUrlFilters } from "@/lib/common/utils";
|
||||
import { trackNoCodeAction } from "@/lib/survey/action";
|
||||
@@ -9,12 +10,14 @@ import {
|
||||
addPageUrlEventListeners,
|
||||
addScrollDepthListener,
|
||||
checkPageUrl,
|
||||
createTrackNoCodeActionWithContext,
|
||||
removeClickEventListener,
|
||||
removeExitIntentListener,
|
||||
removePageUrlEventListeners,
|
||||
removeScrollDepthListener,
|
||||
} from "@/lib/survey/no-code-action";
|
||||
import { setIsSurveyRunning } from "@/lib/survey/widget";
|
||||
import { TActionClassNoCodeConfig } from "@/types/survey";
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/common/config", () => ({
|
||||
@@ -45,10 +48,15 @@ vi.mock("@/lib/common/timeout-stack", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/utils", () => ({
|
||||
handleUrlFilters: vi.fn(),
|
||||
evaluateNoCodeConfigClick: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/common/utils", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- We need this only for type inference
|
||||
const actual = await importOriginal<typeof import("@/lib/common/utils")>();
|
||||
return {
|
||||
...actual,
|
||||
handleUrlFilters: vi.fn(),
|
||||
evaluateNoCodeConfigClick: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/survey/action", () => ({
|
||||
trackNoCodeAction: vi.fn(),
|
||||
@@ -58,13 +66,53 @@ vi.mock("@/lib/survey/widget", () => ({
|
||||
setIsSurveyRunning: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/status", () => ({
|
||||
checkSetup: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("createTrackNoCodeActionWithContext", () => {
|
||||
test("should create a trackNoCodeAction with the correct context", () => {
|
||||
const trackNoCodeActionWithContext = createTrackNoCodeActionWithContext("pageView");
|
||||
expect(trackNoCodeActionWithContext).toBeDefined();
|
||||
});
|
||||
|
||||
test("should log error if trackNoCodeAction fails", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error");
|
||||
vi.mocked(trackNoCodeAction).mockResolvedValue({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "network_error",
|
||||
message: "Network error",
|
||||
status: 500,
|
||||
url: new URL("https://example.com"),
|
||||
responseMessage: "Network error",
|
||||
},
|
||||
});
|
||||
|
||||
const trackNoCodeActionWithContext = createTrackNoCodeActionWithContext("pageView");
|
||||
|
||||
expect(trackNoCodeActionWithContext).toBeDefined();
|
||||
await trackNoCodeActionWithContext("noCodeAction");
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
`🧱 Formbricks - Error in no-code pageView action 'noCodeAction': Network error`,
|
||||
{
|
||||
code: "network_error",
|
||||
message: "Network error",
|
||||
status: 500,
|
||||
url: new URL("https://example.com"),
|
||||
responseMessage: "Network error",
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no-code-event-listeners file", () => {
|
||||
let getInstanceConfigMock: MockInstance<() => Config>;
|
||||
let getInstanceTimeoutStackMock: MockInstance<() => TimeoutStack>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
|
||||
getInstanceTimeoutStackMock = vi.spyOn(TimeoutStack, "getInstance");
|
||||
});
|
||||
@@ -76,6 +124,7 @@ describe("no-code-event-listeners file", () => {
|
||||
test("checkPageUrl calls handleUrlFilters & trackNoCodeAction for matching actionClasses", async () => {
|
||||
(handleUrlFilters as Mock).mockReturnValue(true);
|
||||
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
|
||||
(checkSetup as Mock).mockReturnValue({ ok: true });
|
||||
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
@@ -99,11 +148,10 @@ describe("no-code-event-listeners file", () => {
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
|
||||
const result = await checkPageUrl();
|
||||
await checkPageUrl();
|
||||
|
||||
expect(handleUrlFilters).toHaveBeenCalledWith([{ value: "/some-path", rule: "contains" }]);
|
||||
expect(trackNoCodeAction).toHaveBeenCalledWith("pageViewAction");
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("checkPageUrl removes scheduled timeouts & calls setIsSurveyRunning(false) if invalid url", async () => {
|
||||
@@ -138,12 +186,11 @@ describe("no-code-event-listeners file", () => {
|
||||
|
||||
getInstanceTimeoutStackMock.mockReturnValue(mockTimeoutStack as unknown as TimeoutStack);
|
||||
|
||||
const result = await checkPageUrl();
|
||||
await checkPageUrl();
|
||||
|
||||
expect(trackNoCodeAction).not.toHaveBeenCalled();
|
||||
expect(mockTimeoutStack.remove).toHaveBeenCalledWith(123);
|
||||
expect(setIsSurveyRunning).toHaveBeenCalledWith(false);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("addPageUrlEventListeners adds event listeners to window, patches history if not patched", () => {
|
||||
@@ -262,4 +309,347 @@ describe("no-code-event-listeners file", () => {
|
||||
|
||||
(window.removeEventListener as Mock).mockRestore();
|
||||
});
|
||||
|
||||
// Test cases for Click Event Handlers
|
||||
describe("Click Event Handlers", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
test("addClickEventListener does not add listener if window is undefined", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
addClickEventListener();
|
||||
expect(document.addEventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("addClickEventListener does not re-add listener if already added", () => {
|
||||
vi.stubGlobal("window", {}); // Ensure window is defined
|
||||
addClickEventListener(); // First call
|
||||
expect(document.addEventListener).toHaveBeenCalledTimes(1);
|
||||
addClickEventListener(); // Second call
|
||||
expect(document.addEventListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Test cases for Exit Intent Handlers
|
||||
describe("Exit Intent Handlers", () => {
|
||||
let querySelectorMock: MockInstance;
|
||||
let addEventListenerMock: Mock;
|
||||
let removeEventListenerMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
addEventListenerMock = vi.fn();
|
||||
removeEventListenerMock = vi.fn();
|
||||
|
||||
querySelectorMock = vi.fn().mockReturnValue({
|
||||
addEventListener: addEventListenerMock,
|
||||
removeEventListener: removeEventListenerMock,
|
||||
});
|
||||
|
||||
vi.stubGlobal("document", {
|
||||
querySelector: querySelectorMock,
|
||||
removeEventListener: removeEventListenerMock, // For direct document.removeEventListener calls
|
||||
});
|
||||
(handleUrlFilters as Mock).mockReset(); // Reset mock for each test
|
||||
});
|
||||
|
||||
test("addExitIntentListener does not add if document is undefined", () => {
|
||||
vi.stubGlobal("document", undefined);
|
||||
addExitIntentListener();
|
||||
// No explicit expect, passes if no error. querySelector would not be called.
|
||||
});
|
||||
|
||||
test("addExitIntentListener does not add if body is not found", () => {
|
||||
querySelectorMock.mockReturnValue(null); // body not found
|
||||
addExitIntentListener();
|
||||
expect(addEventListenerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("checkExitIntent does not trigger if clientY > 0", () => {
|
||||
const mockAction = {
|
||||
name: "exitAction",
|
||||
type: "noCode",
|
||||
noCodeConfig: { type: "exitIntent", urlFilters: [] },
|
||||
};
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environment: { data: { actionClasses: [mockAction] } },
|
||||
}),
|
||||
};
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
(handleUrlFilters as Mock).mockReturnValue(true);
|
||||
|
||||
addExitIntentListener();
|
||||
|
||||
expect(handleUrlFilters).not.toHaveBeenCalled();
|
||||
expect(trackNoCodeAction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Test cases for Scroll Depth Handlers
|
||||
describe("Scroll Depth Handlers", () => {
|
||||
let addEventListenerSpy: MockInstance;
|
||||
let removeEventListenerSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
addEventListenerSpy = vi.fn();
|
||||
removeEventListenerSpy = vi.fn();
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: addEventListenerSpy,
|
||||
removeEventListener: removeEventListenerSpy,
|
||||
scrollY: 0,
|
||||
innerHeight: 500,
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
readyState: "complete",
|
||||
documentElement: {
|
||||
scrollHeight: 2000, // bodyHeight > windowSize
|
||||
},
|
||||
});
|
||||
(handleUrlFilters as Mock).mockReset();
|
||||
(trackNoCodeAction as Mock).mockReset();
|
||||
// Reset internal state variables (scrollDepthListenerAdded, scrollDepthTriggered)
|
||||
// This is tricky without exporting them. We can call removeScrollDepthListener
|
||||
// to reset scrollDepthListenerAdded. scrollDepthTriggered is reset if scrollY is 0.
|
||||
removeScrollDepthListener(); // Resets scrollDepthListenerAdded
|
||||
window.scrollY = 0; // Resets scrollDepthTriggered assumption in checkScrollDepth
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("document", undefined);
|
||||
});
|
||||
|
||||
test("addScrollDepthListener does not add if window is undefined", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
addScrollDepthListener();
|
||||
// No explicit expect. Passes if no error.
|
||||
});
|
||||
|
||||
test("addScrollDepthListener does not re-add listener if already added", () => {
|
||||
addScrollDepthListener(); // First call
|
||||
expect(window.addEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
|
||||
expect(window.addEventListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
addScrollDepthListener(); // Second call
|
||||
expect(window.addEventListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("checkScrollDepth does nothing if no fiftyPercentScroll actions", async () => {
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environment: { data: { actionClasses: [] } },
|
||||
}),
|
||||
};
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
|
||||
window.scrollY = 1000; // Past 50%
|
||||
|
||||
addScrollDepthListener();
|
||||
const scrollCallback = addEventListenerSpy.mock.calls[0][1] as () => Promise<void>; // Added type assertion
|
||||
await scrollCallback();
|
||||
|
||||
expect(handleUrlFilters).not.toHaveBeenCalled();
|
||||
expect(trackNoCodeAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("checkScrollDepth does not trigger if scroll < 50%", async () => {
|
||||
const mockAction = {
|
||||
name: "scrollAction",
|
||||
type: "noCode",
|
||||
noCodeConfig: { type: "fiftyPercentScroll", urlFilters: [] },
|
||||
};
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environment: { data: { actionClasses: [mockAction] } },
|
||||
}),
|
||||
};
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
(handleUrlFilters as Mock).mockReturnValue(true);
|
||||
|
||||
window.scrollY = 200; // scrollPosition / (bodyHeight - windowSize) = 200 / (2000 - 500) = 200 / 1500 < 0.5
|
||||
|
||||
addScrollDepthListener();
|
||||
const scrollCallback = addEventListenerSpy.mock.calls[0][1] as () => Promise<void>; // Added type assertion
|
||||
await scrollCallback();
|
||||
|
||||
expect(trackNoCodeAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("checkScrollDepth filters by URL", async () => {
|
||||
(handleUrlFilters as Mock).mockImplementation(
|
||||
(urlFilters: TActionClassNoCodeConfig["urlFilters"]) => urlFilters[0]?.value === "valid-scroll"
|
||||
);
|
||||
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
|
||||
|
||||
const mockActionValid = {
|
||||
name: "scrollValid",
|
||||
type: "noCode",
|
||||
noCodeConfig: { type: "fiftyPercentScroll", urlFilters: [{ value: "valid-scroll" }] },
|
||||
};
|
||||
const mockActionInvalid = {
|
||||
name: "scrollInvalid",
|
||||
type: "noCode",
|
||||
noCodeConfig: { type: "fiftyPercentScroll", urlFilters: [{ value: "invalid-scroll" }] },
|
||||
};
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environment: { data: { actionClasses: [mockActionValid, mockActionInvalid] } },
|
||||
}),
|
||||
};
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
window.scrollY = 1000; // Past 50%
|
||||
|
||||
addScrollDepthListener();
|
||||
const scrollCallback = addEventListenerSpy.mock.calls[0][1] as () => Promise<void>; // Added type assertion
|
||||
await scrollCallback();
|
||||
|
||||
expect(trackNoCodeAction).not.toHaveBeenCalledWith("scrollInvalid");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkPageUrl additional cases", () => {
|
||||
let getInstanceConfigMock: MockInstance<() => Config>;
|
||||
let getInstanceTimeoutStackMock: MockInstance<() => TimeoutStack>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
|
||||
getInstanceTimeoutStackMock = vi.spyOn(TimeoutStack, "getInstance");
|
||||
});
|
||||
|
||||
test("checkPageUrl does nothing if no pageView actionClasses", async () => {
|
||||
(handleUrlFilters as Mock).mockReturnValue(true);
|
||||
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
|
||||
(checkSetup as Mock).mockReturnValue({ ok: true });
|
||||
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environment: {
|
||||
data: {
|
||||
actionClasses: [
|
||||
{
|
||||
name: "clickAction", // Not a pageView action
|
||||
type: "noCode",
|
||||
noCodeConfig: {
|
||||
type: "click",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
vi.stubGlobal("window", { location: { href: "/fail" } });
|
||||
await checkPageUrl();
|
||||
expect(handleUrlFilters).not.toHaveBeenCalled();
|
||||
expect(trackNoCodeAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("checkPageUrl does not remove timeout if not scheduled", async () => {
|
||||
(handleUrlFilters as Mock).mockReturnValue(false); // Invalid URL
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environment: {
|
||||
data: {
|
||||
actionClasses: [
|
||||
{
|
||||
name: "pageViewAction",
|
||||
type: "noCode",
|
||||
noCodeConfig: {
|
||||
type: "pageView",
|
||||
urlFilters: [{ value: "/fail", rule: "contains" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
const mockTimeoutStack = {
|
||||
getTimeouts: vi.fn().mockReturnValue([]), // No scheduled timeouts
|
||||
remove: vi.fn(),
|
||||
add: vi.fn(),
|
||||
};
|
||||
getInstanceTimeoutStackMock.mockReturnValue(mockTimeoutStack as unknown as TimeoutStack);
|
||||
|
||||
vi.stubGlobal("window", { location: { href: "/fail" } });
|
||||
await checkPageUrl();
|
||||
|
||||
expect(mockTimeoutStack.remove).not.toHaveBeenCalled();
|
||||
expect(setIsSurveyRunning).not.toHaveBeenCalledWith(false); // Should not be called if timeout was not present
|
||||
});
|
||||
});
|
||||
|
||||
describe("addPageUrlEventListeners additional cases", () => {
|
||||
test("addPageUrlEventListeners does not add listeners if window is undefined", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
addPageUrlEventListeners(); // Call the function
|
||||
// No explicit expect needed, the test passes if no error is thrown
|
||||
// and no listeners were attempted to be added to an undefined window.
|
||||
// We can also assert that isHistoryPatched remains false if it's exported and settable for testing.
|
||||
// For now, we assume it's an internal detail not directly testable without more mocks.
|
||||
});
|
||||
|
||||
test("addPageUrlEventListeners does not re-add listeners if already added", () => {
|
||||
const addEventListenerMock = vi.fn();
|
||||
vi.stubGlobal("window", { addEventListener: addEventListenerMock });
|
||||
vi.stubGlobal("history", { pushState: vi.fn(), replaceState: vi.fn() });
|
||||
|
||||
addPageUrlEventListeners(); // First call
|
||||
expect(addEventListenerMock).toHaveBeenCalledTimes(5); // hashchange, popstate, pushstate, replacestate, load
|
||||
|
||||
addPageUrlEventListeners(); // Second call
|
||||
expect(addEventListenerMock).toHaveBeenCalledTimes(5); // Should not have been called again
|
||||
|
||||
(window.addEventListener as Mock).mockRestore();
|
||||
});
|
||||
|
||||
test("addPageUrlEventListeners does not patch history if already patched", () => {
|
||||
const addEventListenerMock = vi.fn();
|
||||
const originalPushState = vi.fn();
|
||||
vi.stubGlobal("window", { addEventListener: addEventListenerMock, dispatchEvent: vi.fn() });
|
||||
vi.stubGlobal("history", { pushState: originalPushState, replaceState: vi.fn() });
|
||||
|
||||
// Simulate history already patched
|
||||
// This requires isHistoryPatched to be exported or a way to set it.
|
||||
// Assuming we can't directly set isHistoryPatched from outside,
|
||||
// we call it once to patch, then check if pushState is re-assigned.
|
||||
addPageUrlEventListeners(); // First call, patches history
|
||||
const patchedPushState = history.pushState;
|
||||
|
||||
addPageUrlEventListeners(); // Second call
|
||||
expect(history.pushState).toBe(patchedPushState); // pushState should not be a new function
|
||||
|
||||
// Test patched pushState
|
||||
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
|
||||
patchedPushState.apply(history, [{}, "", "/new-url"]);
|
||||
expect(originalPushState).toHaveBeenCalled();
|
||||
// expect(dispatchEventSpy).toHaveBeenCalledWith(event);
|
||||
|
||||
(window.addEventListener as Mock).mockRestore();
|
||||
dispatchEventSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removePageUrlEventListeners additional cases", () => {
|
||||
test("removePageUrlEventListeners does nothing if window is undefined", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
removePageUrlEventListeners();
|
||||
// No explicit expect. Passes if no error.
|
||||
});
|
||||
|
||||
test("removePageUrlEventListeners does nothing if listeners were not added", () => {
|
||||
const removeEventListenerMock = vi.fn();
|
||||
vi.stubGlobal("window", { removeEventListener: removeEventListenerMock });
|
||||
// Assuming listeners are not added yet (arePageUrlEventListenersAdded is false)
|
||||
removePageUrlEventListeners();
|
||||
(window.removeEventListener as Mock).mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("user.ts", () => {
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
test("successfully sets up formbricks after logout", async () => {
|
||||
test("successfully sets up formbricks after logout", () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: mockEnvironmentId,
|
||||
@@ -129,40 +129,24 @@ describe("user.ts", () => {
|
||||
|
||||
(setup as Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await logout();
|
||||
const result = logout();
|
||||
|
||||
expect(tearDown).toHaveBeenCalled();
|
||||
expect(setup).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
appUrl: mockAppUrl,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("returns error if setup fails", async () => {
|
||||
test("returns error if appConfig.get fails", () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: mockEnvironmentId,
|
||||
appUrl: mockAppUrl,
|
||||
user: { data: { userId: mockUserId } },
|
||||
}),
|
||||
get: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
|
||||
|
||||
const mockError = { code: "network_error", message: "Failed to connect" };
|
||||
(setup as Mock).mockRejectedValue(mockError);
|
||||
const result = logout();
|
||||
|
||||
const result = await logout();
|
||||
|
||||
expect(tearDown).toHaveBeenCalled();
|
||||
expect(setup).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
appUrl: mockAppUrl,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual(mockError);
|
||||
expect(result.error).toEqual(new Error("Failed to logout"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,9 +13,7 @@ export class UpdateQueue {
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): UpdateQueue {
|
||||
if (!UpdateQueue.instance) {
|
||||
UpdateQueue.instance = new UpdateQueue();
|
||||
}
|
||||
UpdateQueue.instance ??= new UpdateQueue();
|
||||
|
||||
return UpdateQueue.instance;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { setup, tearDown } from "@/lib/common/setup";
|
||||
import { tearDown } from "@/lib/common/setup";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type ApiErrorResponse, type NetworkError, type Result, err, okVoid } from "@/types/error";
|
||||
import { type ApiErrorResponse, type Result, err, okVoid } from "@/types/error";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
|
||||
export const setUserId = async (userId: string): Promise<Result<void, ApiErrorResponse>> => {
|
||||
@@ -31,32 +31,22 @@ export const setUserId = async (userId: string): Promise<Result<void, ApiErrorRe
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const logout = async (): Promise<Result<void, NetworkError>> => {
|
||||
const logger = Logger.getInstance();
|
||||
const appConfig = Config.getInstance();
|
||||
|
||||
const { userId } = appConfig.get().user.data;
|
||||
|
||||
if (!userId) {
|
||||
logger.error("No userId is set, please use the setUserId function to set a userId first");
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
logger.debug("Resetting state & getting new state from backend");
|
||||
const initParams = {
|
||||
environmentId: appConfig.get().environmentId,
|
||||
appUrl: appConfig.get().appUrl,
|
||||
};
|
||||
|
||||
// logout the user, remove user state and setup formbricks again
|
||||
tearDown();
|
||||
|
||||
export const logout = (): Result<void> => {
|
||||
try {
|
||||
await setup(initParams);
|
||||
const logger = Logger.getInstance();
|
||||
const appConfig = Config.getInstance();
|
||||
|
||||
const { userId } = appConfig.get().user.data;
|
||||
|
||||
if (!userId) {
|
||||
logger.error("No userId is set, please use the setUserId function to set a userId first");
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
tearDown();
|
||||
|
||||
return okVoid();
|
||||
} catch (e) {
|
||||
const errorTyped = e as { message?: string };
|
||||
logger.error(`Failed to setup formbricks after logout: ${errorTyped.message ?? "Unknown error"}`);
|
||||
return err(e as NetworkError);
|
||||
} catch {
|
||||
return { ok: false, error: new Error("Failed to logout") };
|
||||
}
|
||||
};
|
||||
|
||||